Updating product AI features endpoints (#38930)

This commit is contained in:
Joel Thiessen 2023-07-05 18:39:19 -07:00 committed by GitHub
parent a99ce61fb2
commit 419bd98db7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 164 additions and 1295 deletions

View File

@ -1,23 +0,0 @@
<?php
/**
* API initialization for ai plugin.
*
* @package Woo_AI
*/
defined( 'ABSPATH' ) || exit;
/**
* API initialization for ai plugin.
*
* @package Woo_AI
*/
/**
* Register the Woo AI route.
*/
function woo_ai_rest_api_init() {
require_once dirname( __FILE__ ) . '/product-data-suggestion/class-product-data-suggestion-api.php';
}
add_action( 'rest_api_init', 'woo_ai_rest_api_init' );

View File

@ -1,198 +0,0 @@
<?php
/**
* REST API Attribute Suggestion Controller
*
* Handles requests to /wc-admin/wooai/product-data-suggestions
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\AI\Completion\Jetpack_Completion_Service;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Exception;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Prompt_Generator;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Request;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Service;
use Automattic\WooCommerce\AI\PromptFormatter\Json_Request_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Attribute_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Category_Formatter;
use WC_REST_Data_Controller;
use WP_Error;
use WP_HTTP_Response;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion API controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Product_Data_Suggestion_API extends WC_REST_Data_Controller {
/**
* The product data suggestion service.
*
* @var Product_Data_Suggestion_Service
*/
protected $product_data_suggestion_service;
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wooai';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'product-data-suggestions';
/**
* Constructor
*/
public function __construct() {
$json_request_formatter = new Json_Request_Formatter();
$product_category_formatter = new Product_Category_Formatter();
$product_attribute_formatter = new Product_Attribute_Formatter();
$prompt_generator = new Product_Data_Suggestion_Prompt_Generator( $product_category_formatter, $product_attribute_formatter, $json_request_formatter );
$completion_service = new Jetpack_Completion_Service();
$this->product_data_suggestion_service = new Product_Data_Suggestion_Service( $prompt_generator, $completion_service );
$this->register_routes();
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'get_response' ),
'permission_callback' => array( $this, 'get_response_permission_check' ),
'args' => array(
'requested_data' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
'required' => true,
),
'name' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'description' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'categories' => array(
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
'items' => array(
'type' => 'integer',
),
'default' => array(),
),
'tags' => array(
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'default' => array(),
),
'attributes' => array(
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'default' => array(),
'items' => array(
'type' => 'object',
'properties' => array(
'key' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'value' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
),
),
),
),
)
);
}
/**
* Check if a given request has access to create a product.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|boolean
*/
public function get_response_permission_check( WP_REST_Request $request ) {
if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Get the product-data suggestions.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|WP_HTTP_Response|WP_REST_Response
*/
public function get_response( WP_REST_Request $request ) {
$requested_data = $request->get_param( 'requested_data' );
$name = $request->get_param( 'name' );
$description = $request->get_param( 'description' );
$categories = $request->get_param( 'categories' );
$tags = $request->get_param( 'tags' );
$attributes = $request->get_param( 'attributes' );
// Strip HTML tags from the description.
if ( ! empty( $description ) ) {
$description = wp_strip_all_tags( $request->get_param( 'description' ) );
}
// Check if enough data is provided in the name and description to get suggestions.
if ( strlen( $name ) < 10 && strlen( $description ) < 50 ) {
return new WP_Error( 'error', __( 'Enter a few descriptive words or add product description, tags, or attributes to generate name ideas.', 'woocommerce' ), array( 'status' => 400 ) );
}
try {
$product_data_request = new Product_Data_Suggestion_Request( $requested_data, $name, $description, $tags, $categories, $attributes );
$suggestions = $this->product_data_suggestion_service->get_suggestions( $product_data_request );
} catch ( Product_Data_Suggestion_Exception $e ) {
return new WP_Error( 'error', $e->getMessage(), array( 'status' => $e->getCode() ) );
}
return rest_ensure_response( $suggestions );
}
}
new Product_Data_Suggestion_API();

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Updating AI endpoints for product editing features.

View File

@ -1,17 +0,0 @@
<?php
/**
* Completion Exception Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Completion;
use Automattic\WooCommerce\AI\Exception\Woo_AI_Exception;
defined( 'ABSPATH' ) || exit;
/**
* Completion Exception Class
*/
class Completion_Exception extends Woo_AI_Exception {}

View File

@ -1,188 +0,0 @@
<?php
/**
* Jetpack Completion Service Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Completion;
use Automattic\Jetpack\Connection\Client;
use Jetpack;
use Jetpack_Options;
use JsonException;
use WP_Error;
defined( 'ABSPATH' ) || exit;
/**
* Jetpack Service class.
*/
class Jetpack_Completion_Service implements Completion_Service_Interface {
/**
* The timeout for the completion request.
*/
private const COMPLETION_TIMEOUT = 60;
/**
* Gets the completion from the API.
*
* @param array $arguments An array of arguments to send to the API.
*
* @return string The completion response.
*
* @throws Completion_Exception If the request fails.
*/
public function get_completion( array $arguments ): string {
$this->validate_jetpack_connection();
$site_id = $this->get_site_id();
$response = $this->send_request_to_api( $site_id, $arguments );
return $this->process_response( $response );
}
/**
* Validates the Jetpack connection.
*
* @throws Completion_Exception If Jetpack connection is not ready.
*/
private function validate_jetpack_connection(): void {
if ( ! $this->is_jetpack_ready() ) {
throw new Completion_Exception( __( 'Not connected to Jetpack. Please make sure that Jetpack is active and connected.', 'woocommerce' ), 400 );
}
}
/**
* Returns whether Jetpack connection is ready.
*
* @return bool True if Jetpack connection is ready, false otherwise.
*/
private function is_jetpack_ready(): bool {
return Jetpack::connection()->has_connected_owner() && Jetpack::is_connection_ready();
}
/**
* Gets the Jetpack Site ID.
*
* @return string The Jetpack Site ID.
*
* @throws Completion_Exception If no Jetpack Site ID is found.
*/
private function get_site_id(): string {
$site_id = Jetpack_Options::get_option( 'id' );
if ( ! $site_id ) {
throw new Completion_Exception( __( 'No Jetpack Site ID found. Please make sure that Jetpack is active and connected.', 'woocommerce' ), 400 );
}
return (string) $site_id;
}
/**
* Sends request to the API and gets the response.
*
* @param string $site_id The site ID.
* @param array $arguments An array of arguments to send to the API.
*
* @return array|WP_Error The response from the API.
*/
private function send_request_to_api( string $site_id, array $arguments ) {
return Client::wpcom_json_api_request_as_user(
"/sites/{$site_id}/jetpack-ai/completions",
'2',
array(
'method' => 'POST',
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'timeout' => self::COMPLETION_TIMEOUT,
),
$arguments
);
}
/**
* Processes the API response.
*
* @param array|WP_Error $response The response from the API.
*
* @return string The completion response.
*
* @throws Completion_Exception If there's an error in the response.
*/
private function process_response( $response ): string {
if ( is_wp_error( $response ) ) {
$this->handle_wp_error( $response );
}
if ( ! isset( $response['response']['code'] ) || 200 !== $response['response']['code'] ) {
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to get completion ( reason: %s )', 'woocommerce' ), $response['response']['message'] ), 400 );
}
$response_body = wp_remote_retrieve_body( $response );
try {
// Extract the string from the response. Response might be wrapped in quotes and escaped. E.g. "{ \n \"foo\": \"bar\" \n }".
$decoded = json_decode( $response_body, true, 512, JSON_THROW_ON_ERROR );
} catch ( JsonException $e ) {
$this->handle_json_exception( $e );
}
return $this->get_completion_string_from_decoded_response( $decoded, $response_body );
}
/**
* Handles WP_Error response.
*
* @param WP_Error $response The WP_Error response.
*
* @throws Completion_Exception With the error message from the response.
*/
private function handle_wp_error( WP_Error $response ): void {
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to get completion ( reason: %s )', 'woocommerce' ), $response->get_error_message() ), 400 );
}
/**
* Handles JSON parsing exceptions.
*
* @param JsonException $e The JSON exception.
*
* @throws Completion_Exception With the error message from the JSON exception.
*/
private function handle_json_exception( JsonException $e ): void {
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to decode completion response ( reason: %s )', 'woocommerce' ), $e->getMessage() ), 500, $e );
}
/**
* Retrieves the completion string from the decoded response.
*
* @param mixed $decoded The decoded JSON response.
* @param string $response_body The original response body.
*
* @return string The completion string.
*
* @throws Completion_Exception If the decoded response is invalid or empty.
*/
private function get_completion_string_from_decoded_response( $decoded, string $response_body ): string {
if ( ! is_string( $decoded ) ) {
// Check if the response is an error.
if ( isset( $decoded['code'] ) ) {
$error_message = $decoded['message'] ?? $decoded['code'];
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to get completion ( reason: %s )', 'woocommerce' ), $error_message ), 400 );
}
// If the decoded response is not an error, it means that the response was not wrapped in quotes and escaped, so we can use it as is.
$decoded = $response_body;
}
if ( empty( $decoded ) || ! is_string( $decoded ) ) {
/* translators: %s: The response body. */
throw new Completion_Exception( sprintf( __( 'Invalid or empty completion response: %s', 'woocommerce' ), $response_body ), 500 );
}
return $decoded;
}
}

View File

@ -1,26 +0,0 @@
<?php
/**
* Completion Service Interface
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Completion;
defined( 'ABSPATH' ) || exit;
/**
* Completion Service Interface.
*/
interface Completion_Service_Interface {
/**
* Gets the completion from the API.
*
* @param array $arguments An array of arguments to send to the API.
*
* @return string The completion response.
*
* @throws Completion_Exception If the request fails.
*/
public function get_completion( array $arguments ): string;
}

View File

@ -1,17 +0,0 @@
<?php
/**
* Woo AI Exception Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Exception;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Woo AI Exception Class
*/
class Woo_AI_Exception extends Exception {}

View File

@ -1,17 +0,0 @@
<?php
/**
* Product Data Suggestion Exception Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
use Automattic\WooCommerce\AI\Exception\Woo_AI_Exception;
defined( 'ABSPATH' ) || exit;
/**
* Product Data Suggestion Exception Class
*/
class Product_Data_Suggestion_Exception extends Woo_AI_Exception {}

View File

@ -1,163 +0,0 @@
<?php
/**
* Woo AI Attribute Suggestion Prompt Generator Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
use Automattic\WooCommerce\AI\PromptFormatter\Json_Request_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Attribute_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Category_Formatter;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion Prompt Generator Class
*/
class Product_Data_Suggestion_Prompt_Generator {
private const PROMPT_TEMPLATE = <<<PROMPT_TEMPLATE
You are a WooCommerce SEO and marketing expert.
Using the product's name, description, tags, categories, and other attributes,
provide three optimized alternatives to the product's %s to enhance the store's SEO performance and sales.
Provide the best option for the product's %s based on the product properties.
Identify the language used in the given title and use the same language in your response.
Return only the alternative value for product's %s in the "content" part of your response.
Product titles should contain at least 20 characters.
Return a short and concise reason for each suggestion in seven words in the "reason" part of your response.
The product's properties are:
%s
PROMPT_TEMPLATE;
/**
* The product category formatter.
*
* @var Product_Category_Formatter
*/
protected $product_category_formatter;
/**
* The JSON request formatter.
*
* @var Json_Request_Formatter
*/
protected $json_request_formatter;
/**
* The product attribute formatter.
*
* @var Product_Attribute_Formatter
*/
protected $product_attribute_formatter;
/**
* Product_Data_Suggestion_Prompt_Generator constructor.
*
* @param Product_Category_Formatter $product_category_formatter The product category formatter.
* @param Product_Attribute_Formatter $product_attribute_formatter The product attribute formatter.
* @param Json_Request_Formatter $json_request_formatter The JSON request formatter.
*/
public function __construct( Product_Category_Formatter $product_category_formatter, Product_Attribute_Formatter $product_attribute_formatter, Json_Request_Formatter $json_request_formatter ) {
$this->product_category_formatter = $product_category_formatter;
$this->product_attribute_formatter = $product_attribute_formatter;
$this->json_request_formatter = $json_request_formatter;
}
/**
* Build the user prompt based on the request.
*
* @param Product_Data_Suggestion_Request $request The request to build the prompt for.
*
* @return string
*/
public function get_user_prompt( Product_Data_Suggestion_Request $request ): string {
$request_prompt = $this->get_request_prompt( $request );
$prompt = sprintf(
self::PROMPT_TEMPLATE,
$request->requested_data,
$request->requested_data,
$request->requested_data,
$request_prompt
);
// Append the JSON request prompt.
$prompt .= "\n" . $this->get_example_response( $request );
return $prompt;
}
/**
* Build a prompt for the request.
*
* @param Product_Data_Suggestion_Request $request The request to build the prompt for.
*
* @return string
*/
private function get_request_prompt( Product_Data_Suggestion_Request $request ): string {
$request_prompt = '';
if ( ! empty( $request->name ) ) {
$request_prompt .= sprintf(
"\nName: %s",
$request->name
);
}
if ( ! empty( $request->description ) ) {
$request_prompt .= sprintf(
"\nDescription: %s",
$request->description
);
}
if ( ! empty( $request->tags ) ) {
$request_prompt .= sprintf(
"\nTags (comma separated): %s",
implode( ', ', $request->tags )
);
}
if ( ! empty( $request->categories ) ) {
$request_prompt .= sprintf(
"\nCategories (comma separated, child categories separated with >): %s",
$this->product_category_formatter->format( $request->categories )
);
}
if ( ! empty( $request->attributes ) ) {
$request_prompt .= sprintf(
"\n%s",
$this->product_attribute_formatter->format( $request->attributes )
);
}
return $request_prompt;
}
/**
* Get an example response for the request.
*
* @param Product_Data_Suggestion_Request $request The request to build the example response for.
*
* @return string
*/
private function get_example_response( Product_Data_Suggestion_Request $request ): string {
$response_array = array(
'suggestions' => array(
array(
'content' => sprintf( 'An improved alternative to the product\'s %s', $request->requested_data ),
'reason' => sprintf( 'First concise reason why this %s helps the SEO and sales of the product.', $request->requested_data ),
),
array(
'content' => sprintf( 'Another improved alternative to the product\'s %s', $request->requested_data ),
'reason' => sprintf( 'Second concise reason this %s helps the SEO and sales of the product.', $request->requested_data ),
),
),
);
return $this->json_request_formatter->format( $response_array );
}
}

View File

@ -1,109 +0,0 @@
<?php
/**
* Woo AI Attribute Suggestion Request Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion Request class.
*/
class Product_Data_Suggestion_Request {
const REQUESTED_DATA_NAME = 'name';
const REQUESTED_DATA_DESCRIPTION = 'description';
const REQUESTED_DATA_TAGS = 'tags';
const REQUESTED_DATA_CATEGORIES = 'categories';
/**
* Name of the product data that is being requested (e.g. title, description, tags, etc.).
*
* @var string
*/
public $requested_data;
/**
* The name of the product.
*
* @var string
*/
public $name;
/**
* The description of the product.
*
* @var string
*/
public $description;
/**
* The product tags.
*
* @var string[]
*/
public $tags;
/**
* Categories of the product as an associative array.
*
* @var string[]
*/
public $categories;
/**
* Other attributes of the product as an associative array.
*
* @var array[] Associative array of attributes. Each attribute is an associative array with the following keys:
* - name: The name of the attribute.
* - value: The value of the attribute.
*/
public $attributes;
/**
* Constructor
*
* @param string $requested_data The key for the product data that suggestions are being requested for.
* @param string $name The name of the product.
* @param string $description The description of the product.
* @param string[] $tags The product tags.
* @param integer[] $categories Category IDs of the product as an associative array.
* @param array[] $attributes Other attributes of the product as an associative array.
*
* @throws Product_Data_Suggestion_Exception If the requested attribute is invalid.
*/
public function __construct( string $requested_data, string $name, string $description, array $tags = array(), array $categories = array(), array $attributes = array() ) {
$this->validate_requested_data( $requested_data );
$this->requested_data = $requested_data;
$this->name = $name;
$this->description = $description;
$this->tags = $tags;
$this->categories = $categories;
$this->attributes = $attributes;
}
/**
* Validates the requested attribute.
*
* @param string $requested_data The attribute that suggestions are being requested for.
*
* @return void
*
* @throws Product_Data_Suggestion_Exception If the requested data is invalid.
*/
private function validate_requested_data( string $requested_data ): void {
$valid_requested_data_keys = array(
self::REQUESTED_DATA_NAME,
self::REQUESTED_DATA_DESCRIPTION,
self::REQUESTED_DATA_TAGS,
self::REQUESTED_DATA_CATEGORIES,
);
if ( ! in_array( $requested_data, $valid_requested_data_keys, true ) ) {
throw new Product_Data_Suggestion_Exception( 'Invalid requested data.', 400 );
}
}
}

View File

@ -1,78 +0,0 @@
<?php
/**
* Woo AI Attribute Suggestion Service Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
use Automattic\WooCommerce\AI\Completion\Completion_Exception;
use Automattic\WooCommerce\AI\Completion\Completion_Service_Interface;
use JsonException;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion Service class.
*/
class Product_Data_Suggestion_Service {
/**
* The prompt generator.
*
* @var Product_Data_Suggestion_Prompt_Generator
*/
protected $prompt_generator;
/**
* The completion service.
*
* @var Completion_Service_Interface
*/
protected $completion_service;
/**
* Constructor
*
* @param Product_Data_Suggestion_Prompt_Generator $prompt_generator The prompt generator.
* @param Completion_Service_Interface $completion_service The completion service.
*/
public function __construct( Product_Data_Suggestion_Prompt_Generator $prompt_generator, Completion_Service_Interface $completion_service ) {
$this->prompt_generator = $prompt_generator;
$this->completion_service = $completion_service;
}
/**
* Get suggestions for the given request.
*
* @param Product_Data_Suggestion_Request $request The request.
*
* @return array An array of suggestions. Each suggestion is an associative array with the following keys:
* - content: The suggested content.
* - reason: The reason for the suggestion.
*
* @throws Product_Data_Suggestion_Exception If If getting the suggestions fails or the suggestions cannot be decoded from JSON.
*/
public function get_suggestions( Product_Data_Suggestion_Request $request ): array {
$arguments = array(
'content' => $this->prompt_generator->get_user_prompt( $request ),
'skip_cache' => true,
'feature' => 'woo_ai_plugin',
);
try {
$completion = $this->completion_service->get_completion( $arguments );
} catch ( Completion_Exception $e ) {
/* translators: %s: The error message. */
throw new Product_Data_Suggestion_Exception( sprintf( __( 'Failed to fetch the suggestions: %s', 'woocommerce' ), $e->getMessage() ), $e->getCode(), $e );
}
try {
return json_decode( $completion, true, 512, JSON_THROW_ON_ERROR );
} catch ( JsonException $e ) {
throw new Product_Data_Suggestion_Exception( 'Failed to decode the suggestions. Please try again.', 400, $e );
}
}
}

View File

@ -1,57 +0,0 @@
<?php
/**
* JSON Request Formatter class.
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* JSON Request Formatter class.
*/
class Json_Request_Formatter implements Prompt_Formatter_Interface {
const JSON_REQUEST_PROMPT = <<<JSON_REQUEST_PROMPT
Structure your response as JSON (RFC 8259), similar to this example:
%s
You speak only JSON (RFC 8259). Output only the JSON response. Do not say anything else or use normal text.
JSON_REQUEST_PROMPT;
/**
* Generates a prompt so that we can get a JSON response from the completion API.
*
* @param array $data An example array of data to include in the request as a possible JSON response.
*
* @return string
*
* @throws InvalidArgumentException If the input data is not valid.
*/
public function format( $data ): string {
if ( ! $this->validate_data( $data ) ) {
throw new InvalidArgumentException( 'Invalid input data. Provide an array.' );
}
return sprintf(
self::JSON_REQUEST_PROMPT,
wp_json_encode( $data )
);
}
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool {
return ! empty( $data ) && is_array( $data );
}
}

View File

@ -1,82 +0,0 @@
<?php
/**
* Product Attribute Formatter Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Product Attribute Formatter class.
*/
class Product_Attribute_Formatter implements Prompt_Formatter_Interface {
/**
* The attribute labels.
*
* @var array
*/
private $attribute_labels;
/**
* Product_Attribute_Formatter constructor.
*/
public function __construct() {
$this->attribute_labels = wc_get_attribute_taxonomy_labels();
}
/**
* Format attributes for the prompt
*
* @param array $data An associative array of attributes with the format { "name": "name", "value": "value" }.
*
* @return string A string containing the formatted attributes.
*
* @throws InvalidArgumentException If the input data is not valid.
*/
public function format( $data ): string {
if ( ! $this->validate_data( $data ) ) {
throw new InvalidArgumentException( 'Invalid input data. Provide an array of attributes.' );
}
$formatted_attributes = '';
foreach ( $data as $attribute ) {
// Skip if the attribute value is empty or if the attribute label is empty.
if ( empty( $attribute['value'] ) || empty( $this->attribute_labels[ $attribute['name'] ] ) ) {
continue;
}
$label = $this->attribute_labels[ $attribute['name'] ];
$formatted_attributes .= sprintf( "%s: \"%s\"\n", $label, $attribute['value'] );
}
return $formatted_attributes;
}
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool {
if ( empty( $data ) || ! is_array( $data ) ) {
return false;
}
foreach ( $data as $attribute ) {
if ( empty( $attribute['name'] ) ) {
return false;
}
}
return true;
}
}

View File

@ -1,104 +0,0 @@
<?php
/**
* Product Category Formatter Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
use Exception;
use InvalidArgumentException;
use WP_Term;
defined( 'ABSPATH' ) || exit;
/**
* Product Category Formatter class.
*/
class Product_Category_Formatter implements Prompt_Formatter_Interface {
private const CATEGORY_TAXONOMY = 'product_cat';
private const PARENT_CATEGORY_SEPARATOR = ' > ';
private const UNCATEGORIZED_SLUG = 'uncategorized';
/**
* Get the category names from the category ids from WooCommerce and recursively get all the parent categories and prepend them to the category names separated by >.
*
* @param array $data The category ids.
*
* @return string A string containing the formatted categories. E.g., "Books > Fiction, Books > Novels > Fiction"
*
* @throws InvalidArgumentException If the input data is not an array.
*/
public function format( $data ): string {
if ( ! $this->validate_data( $data ) ) {
throw new InvalidArgumentException( 'Invalid input data. Provide an array of category ids.' );
}
$categories = array();
foreach ( $data as $category_id ) {
$category = get_term( $category_id, self::CATEGORY_TAXONOMY );
// If the category is not found, or it is the uncategorized category, skip it.
if ( ! $category instanceof WP_Term || self::UNCATEGORIZED_SLUG === $category->slug ) {
continue;
}
$categories[] = $this->format_category_name( $category );
}
return implode( ', ', $categories );
}
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool {
return ! empty( $data ) && is_array( $data );
}
/**
* Get formatted category name with parent categories prepended separated by >.
*
* @param WP_Term $category The category as a WP_Term object.
*
* @return string The formatted category name.
*/
private function format_category_name( WP_Term $category ): string {
$parent_categories = $this->get_parent_categories( $category->term_id );
$parent_categories[] = $category->name;
return implode( self::PARENT_CATEGORY_SEPARATOR, $parent_categories );
}
/**
* Get parent categories for the given category id.
*
* @param int $category_id The category id.
*
* @return array An array of names of the parent categories in the order of the hierarchy.
*/
private function get_parent_categories( int $category_id ): array {
$parent_category_ids = get_ancestors( $category_id, self::CATEGORY_TAXONOMY );
if ( empty( $parent_category_ids ) ) {
return array();
}
$parent_category_ids = array_reverse( $parent_category_ids );
return array_map(
function ( $parent_category_id ) {
$parent_category = get_term( $parent_category_id, self::CATEGORY_TAXONOMY );
return $parent_category->name;
},
$parent_category_ids
);
}
}

View File

@ -1,33 +0,0 @@
<?php
/**
* Prompt Formatter Interface.
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
defined( 'ABSPATH' ) || exit;
/**
* Prompt Formatter Interface.
*/
interface Prompt_Formatter_Interface {
/**
* Formats the data into a prompt.
*
* @param mixed $data The data to format.
*
* @return string The formatted prompt.
*/
public function format( $data ): string;
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool;
}

View File

@ -6,12 +6,12 @@ import { useRef, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { askQuestion } from '../utils';
import { getCompletion } from '../utils';
type StopReason = 'abort' | 'finished' | 'error' | 'interrupted';
type UseCompletionProps = {
onStreamMessage: ( message: string, chunk: string ) => void;
onStreamMessage?: ( message: string, chunk: string ) => void;
onCompletionFinished?: (
reason: StopReason,
previousContent: string
@ -20,7 +20,7 @@ type UseCompletionProps = {
};
export const useCompletion = ( {
onStreamMessage,
onStreamMessage = () => {},
onCompletionFinished = () => {},
onStreamError = () => {},
}: UseCompletionProps ) => {
@ -56,7 +56,7 @@ export const useCompletion = ( {
onStreamError( error );
};
const requestCompletion = async ( question: string ) => {
const requestCompletion = async ( prompt: string ) => {
if ( completionSource.current ) {
stopCompletion( 'interrupted' );
}
@ -65,7 +65,7 @@ export const useCompletion = ( {
let suggestionsSource;
try {
suggestionsSource = await askQuestion( question );
suggestionsSource = await getCompletion( prompt );
} catch ( e ) {
// eslint-disable-next-line no-console
console.debug( 'Completion connection error encountered', e );

View File

@ -12,6 +12,7 @@ import {
ProductDataSuggestionRequest,
ApiErrorResponse,
} from '../utils/types';
import { requestJetpackToken } from '../utils';
type ProductDataSuggestionSuccessResponse = {
suggestions: ProductDataSuggestion[];
@ -24,14 +25,15 @@ export const useProductDataSuggestions = () => {
request: ProductDataSuggestionRequest
): Promise< ProductDataSuggestion[] > => {
try {
const response =
const token = await requestJetpackToken();
const { suggestions } =
await apiFetch< ProductDataSuggestionSuccessResponse >( {
path: '/wooai/product-data-suggestions',
method: 'POST',
data: request,
data: { ...request, token },
} );
return response.suggestions;
return suggestions;
} catch ( error ) {
/* eslint-disable-next-line no-console */
console.error( error );

View File

@ -126,10 +126,10 @@ export function WriteItForMeButtonContainer() {
0,
MAX_TITLE_LENGTH
) }."`,
'Identify the language used in this product title and use the same language in your response.',
'Use a 9th grade reading level.',
`Make the description ${ DESCRIPTION_MAX_LENGTH } words or less.`,
'Structure the description into paragraphs using standard HTML <p> tags.',
'Identify the language used in this product title and use the same language in your response.',
'Only if appropriate, use <ul> and <li> tags to list product features.',
'When appropriate, use <strong> and <em> tags to emphasize text.',
'Do not include a top-level heading at the beginning description.',

View File

@ -10,12 +10,19 @@ import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
*/
import MagicIcon from '../../assets/images/icons/magic.svg';
import AlertIcon from '../../assets/images/icons/alert.svg';
import { productData } from '../utils';
import { useProductDataSuggestions, useProductSlug } from '../hooks';
import {
ProductDataSuggestion,
ProductDataSuggestionRequest,
} from '../utils/types';
getProductName,
getPostId,
getPublishingStatus,
isProductDownloadable,
isProductVirtual,
getProductType,
getCategories,
getTags,
getAttributes,
} from '../utils';
import { useCompletion, useProductSlug } from '../hooks';
import { ProductDataSuggestion } from '../utils/types';
import { SuggestionItem, PoweredByLink, recordNameTracks } from './index';
import { RandomLoadingMessage } from '../components';
@ -56,8 +63,35 @@ export const ProductNameSuggestions = () => {
const [ suggestions, setSuggestions ] = useState< ProductDataSuggestion[] >(
[]
);
const { fetchSuggestions } = useProductDataSuggestions();
const { updateProductSlug } = useProductSlug();
const { requestCompletion } = useCompletion( {
onStreamError: ( error ) => {
// eslint-disable-next-line no-console
console.debug( 'Streaming error encountered', error );
recordNameTracks( 'stop', {
reason: 'error',
error: ( error as { message?: string } )?.message || '',
} );
setSuggestionsState( SuggestionsState.Failed );
},
onCompletionFinished: ( reason, content ) => {
try {
const parsed = JSON.parse( content );
setSuggestions( parsed.suggestions );
setSuggestionsState( SuggestionsState.None );
recordNameTracks( 'stop', {
reason: 'finished',
suggestions: parsed.suggestions,
} );
setSuggestions( parsed.suggestions );
setIsFirstLoad( false );
} catch ( e ) {
throw new Error( 'Unable to parse suggestions' );
}
},
} );
const nameInputRef = useRef< HTMLInputElement >(
document.querySelector( '#title' )
);
@ -158,17 +192,12 @@ export const ProductNameSuggestions = () => {
updateProductName( suggestion.content );
setSuggestions( [] );
const productId = getPostId();
const publishingStatus = getPublishingStatus();
// Update product slug if product is a draft.
const currentProductData = productData();
if (
currentProductData.product_id !== null &&
currentProductData.publishing_status === 'draft'
) {
if ( productId !== null && publishingStatus === 'draft' ) {
try {
updateProductSlug(
suggestion.content,
currentProductData.product_id
);
updateProductSlug( suggestion.content, productId );
} catch ( e ) {
// Log silently if slug update fails.
/* eslint-disable-next-line no-console */
@ -177,42 +206,60 @@ export const ProductNameSuggestions = () => {
}
};
const buildPrompt = () => {
const validProductData = Object.entries( {
name: getProductName(),
tags: getTags(),
attributes: getAttributes(),
product_type: getProductType(),
is_downloadable: isProductDownloadable(),
is_virtual: isProductVirtual(),
} ).reduce( ( acc, [ key, value ] ) => {
if (
typeof value === 'boolean' ||
( value instanceof Array
? Boolean( value.length )
: Boolean( value ) )
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
acc[ key ] = value;
}
return acc;
}, {} );
const instructions = [
'You are a WooCommerce SEO and marketing expert.',
"Using the product's name, description, tags, categories, and other attributes, provide three optimized alternatives to the product's title to enhance the store's SEO performance and sales.",
"Provide the best option for the product's title based on the product properties.",
'Identify the language used in the given title and use the same language in your response.',
'Return only the alternative value for product\'s title in the "content" part of your response.',
'Product titles should contain at least 20 characters.',
'Return a short and concise reason for each suggestion in seven words in the "reason" part of your response.',
"The product's properties are:",
`${ JSON.stringify( validProductData ) }`,
'Here is an example of a valid response:',
'{"suggestions": [{"content": "An improved alternative to the product\'s title", "reason": "Reason for the suggestion"}, {"content": "Another improved alternative to the product title", "reason": "Reason for this suggestion"}]}',
];
return instructions.join( '\n' );
};
const fetchProductSuggestions = async (
event: React.MouseEvent< HTMLElement >
) => {
if ( ( event.target as Element )?.closest( 'a' ) ) {
return;
}
setSuggestions( [] );
setSuggestionsState( SuggestionsState.Fetching );
try {
const currentProductData = productData();
recordNameTracks( 'start', {
current_title: currentProductData.name,
} );
recordNameTracks( 'start', {
current_title: getProductName(),
} );
const request: ProductDataSuggestionRequest = {
requested_data: 'name',
...currentProductData,
};
const suggestionResults = await fetchSuggestions( request );
recordNameTracks( 'stop', {
reason: 'finished',
suggestions: suggestionResults,
} );
setSuggestions( suggestionResults );
setSuggestionsState( SuggestionsState.None );
setIsFirstLoad( false );
} catch ( e ) {
recordNameTracks( 'stop', {
reason: 'error',
error: ( e as { message?: string } )?.message || '',
} );
setSuggestionsState( SuggestionsState.Failed );
}
requestCompletion( buildPrompt() );
};
const shouldRenderSuggestionsButton = useCallback( () => {
@ -247,18 +294,16 @@ export const ProductNameSuggestions = () => {
) }
{ productName.length < MIN_TITLE_LENGTH &&
suggestionsState === SuggestionsState.None && (
<>
<div className="wc-product-name-suggestions__tip-message">
<div>
<MagicImage />
{ __(
'Enter a few descriptive words to generate product name.',
'woocommerce'
) }
</div>
<PoweredByLink />
<div className="wc-product-name-suggestions__tip-message">
<div>
<MagicImage />
{ __(
'Enter a few descriptive words to generate product name.',
'woocommerce'
) }
</div>
</>
<PoweredByLink />
</div>
) }
{ suggestionsState !== SuggestionsState.Failed && (
<button

View File

@ -1,6 +1,6 @@
export * from './productData';
export * from './shuffleArray';
export * from './jetpack-completion';
export * from './text-completion';
export * from './recordTracksFactory';
export * from './get-post-id';
export * from './tiny-tools';

View File

@ -1,32 +1,27 @@
/**
* Internal dependencies
*/
import { Attribute, ProductData } from './types';
import { getTinyContent, getPostId } from '.';
import { Attribute } from './types';
import { getTinyContent } from '.';
export const getCategories = () => {
return Array.from(
document.querySelectorAll(
'#taxonomy-product_cat input[name="tax_input[product_cat][]"]'
)
)
.filter(
( item ) =>
window.getComputedStyle( item, ':before' ).content !== 'none'
)
.map( ( item ) => item.nextSibling?.nodeValue?.trim() )
.filter( Boolean );
};
const isElementVisible = ( element: HTMLElement ) =>
! ( window.getComputedStyle( element ).display === 'none' );
const getCategories = (): string[] => {
const categoryCheckboxEls: NodeListOf< HTMLInputElement > =
document.querySelectorAll(
'#taxonomy-product_cat input[name="tax_input[product_cat][]"]:checked'
);
const tempCategories: string[] = [];
categoryCheckboxEls.forEach( ( el ) => {
if ( ! el.value.length ) {
return;
}
tempCategories.push( el.value );
} );
return tempCategories;
};
const getTags = (): string[] => {
export const getTags = (): string[] => {
const tagsEl: HTMLTextAreaElement | null = document.querySelector(
'textarea[name="tax_input[product_tag]"]'
);
@ -36,36 +31,24 @@ const getTags = (): string[] => {
return tags.filter( ( tag ) => tag !== '' );
};
const getAttributes = (): Attribute[] => {
const attributeSelectEls: NodeListOf< HTMLSelectElement > =
document.querySelectorAll(
"#product_attributes select[name^='attribute_values']"
);
export const getAttributes = (): Attribute[] => {
const attributeContainerEls = Array.from(
document.querySelectorAll( '.woocommerce_attribute_data' )
);
const tempAttributes: Attribute[] = [];
attributeSelectEls.forEach( ( el: HTMLSelectElement ) => {
const attributeName =
el.getAttribute( 'data-taxonomy' )?.replace( 'pa_', '' ) || '';
const attributeValues = Array.from( el.selectedOptions )
.map( ( option ) => option.text )
.join( ',' );
if ( ! attributeValues || ! attributeName ) {
return;
return attributeContainerEls.reduce( ( acc, item ) => {
const name = (
item.querySelector( 'input.attribute_name' ) as HTMLInputElement
)?.value;
const value = item.querySelector( 'textarea' )?.textContent;
if ( name && value ) {
acc.push( { name, value } );
}
tempAttributes.push( {
name: attributeName,
value: attributeValues,
} );
} );
return tempAttributes;
return acc;
}, [] as Attribute[] );
};
const getDescription = (): string => {
export const getDescription = (): string => {
const isBlockEditor =
document.querySelectorAll( '.block-editor' ).length > 0;
@ -88,37 +71,25 @@ const getDescription = (): string => {
)?.value;
};
const getProductName = (): string => {
export const getProductName = (): string => {
const productNameEl: HTMLInputElement | null =
document.querySelector( '#title' );
return productNameEl ? productNameEl.value : '';
};
const getProductType = () => {
export const getProductType = () => {
const productTypeEl: HTMLInputElement | null =
document.querySelector( '#product-type' );
return productTypeEl ? productTypeEl.value : '';
};
export const productData = (): ProductData => {
return {
product_id: getPostId(),
name: getProductName(),
categories: getCategories(),
tags: getTags(),
attributes: getAttributes(),
description: getDescription(),
product_type: getProductType(),
is_downloadable: (
document.querySelector( '#_downloadable' ) as HTMLInputElement
)?.checked,
is_virtual: (
document.querySelector( '#_virtual' ) as HTMLInputElement
)?.checked,
publishing_status: (
document.querySelector( '#post_status' ) as HTMLInputElement
)?.value,
};
};
export const getPublishingStatus = () =>
( document.querySelector( '#post_status' ) as HTMLInputElement )?.value;
export const isProductVirtual = () =>
( document.querySelector( '#_virtual' ) as HTMLInputElement )?.checked;
export const isProductDownloadable = () =>
( document.querySelector( '#_downloadable' ) as HTMLInputElement )?.checked;

View File

@ -28,8 +28,7 @@ declare global {
*
* @return {Promise<{token: string, blogId: string}>} The token and the blogId
*/
async function requestToken() {
// Trying to pick the token from localStorage
export async function requestJetpackToken() {
const token = localStorage.getItem( JWT_TOKEN_ID );
let tokenData;
@ -49,6 +48,7 @@ async function requestToken() {
const apiNonce = window.JP_CONNECTION_INITIAL_STATE?.apiNonce;
const siteSuffix = window.JP_CONNECTION_INITIAL_STATE?.siteSuffix;
try {
const data: { token: string; blog_id: string } = await apiFetch( {
path: '/jetpack/v4/jetpack-ai-jwt?_cacheBuster=' + Date.now(),
@ -61,18 +61,14 @@ async function requestToken() {
const newTokenData = {
token: data.token,
/**
* TODO: make sure we return id from the .com token acquisition endpoint too
*/
blogId: siteSuffix,
/**
* Let's expire the token in 2 minutes
* Let's expire the token in 5 minutes
*/
expire: Date.now() + JWT_TOKEN_EXPIRATION_TIME,
};
// Store the token in localStorage
debugToken( 'Storing new token' );
localStorage.setItem( JWT_TOKEN_ID, JSON.stringify( newTokenData ) );
@ -85,23 +81,18 @@ async function requestToken() {
/**
* Leaving this here to make it easier to debug the streaming API calls for now
*
* @param {string} question - The query to send to the API
* @param {number} postId - The post where this completion is being requested, if available
* @param {string} prompt - The query to send to the API
*/
export async function askQuestion( question: string, postId = null ) {
const { token } = await requestToken();
export async function getCompletion( prompt: string ) {
const { token } = await requestJetpackToken();
const url = new URL(
'https://public-api.wordpress.com/wpcom/v2/jetpack-ai-query'
'https://public-api.wordpress.com/wpcom/v2/text-completion/stream'
);
url.searchParams.append( 'question', question );
url.searchParams.append( 'prompt', prompt );
url.searchParams.append( 'token', token );
url.searchParams.append( 'feature', WOO_AI_PLUGIN_FEATURE_NAME );
if ( postId ) {
url.searchParams.append( 'post_id', postId );
}
const source = new EventSource( url.toString() );
return source;
return new EventSource( url.toString() );
}

View File

@ -3,19 +3,6 @@ export type Attribute = {
value: string;
};
export type ProductData = {
product_id: number | null;
name: string;
description: string;
categories: string[];
tags: string[];
attributes: Attribute[];
product_type: string;
is_downloadable: boolean;
is_virtual: boolean;
publishing_status: string;
};
export type ProductDataSuggestion = {
reason: string;
content: string;

View File

@ -81,23 +81,4 @@ function _woo_ai_bootstrap(): void {
}
add_action(
'wp_loaded',
function () {
require 'api/api.php';
require_once dirname( __FILE__ ) . '/includes/exception/class-woo-ai-exception.php';
require_once dirname( __FILE__ ) . '/includes/completion/class-completion-exception.php';
require_once dirname( __FILE__ ) . '/includes/completion/interface-completion-service.php';
require_once dirname( __FILE__ ) . '/includes/completion/class-jetpack-completion-service.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/interface-prompt-formatter.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/class-product-category-formatter.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/class-product-attribute-formatter.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/class-json-request-formatter.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-exception.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-request.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-prompt-generator.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-service.php';
}
);
add_action( 'plugins_loaded', '_woo_ai_bootstrap' );