From 55bca2418fca13968f0b5530a6e51022d5fac650 Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Thu, 1 Aug 2024 11:42:31 -0700 Subject: [PATCH 1/8] initialize Azure Language provider --- .../Classifai/Providers/Azure/Language.php | 151 ++++++++++++++++++ .../Classifai/Services/LanguageProcessing.php | 1 + 2 files changed, 152 insertions(+) create mode 100644 includes/Classifai/Providers/Azure/Language.php diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php new file mode 100644 index 000000000..72e113c4d --- /dev/null +++ b/includes/Classifai/Providers/Azure/Language.php @@ -0,0 +1,151 @@ +feature_instance = $feature_instance; + } + + /** + * This method will be called by the feature to render the fields + * required by the provider, such as API key, endpoint URL, etc. + * + * This should also register settings that are required for the feature + * to work. + */ + public function render_provider_fields() { + $settings = $this->feature_instance->get_settings( static::ID ); + $id = 'endpoint_url'; + + $this->add_api_key_field(); + add_settings_field( + $id, + $args['label'] ?? esc_html__( 'Endpoint URL', 'classifai' ), + [ $this->feature_instance, 'render_input' ], + $this->feature_instance->get_option_name(), + $this->feature_instance->get_option_name() . '_section', + [ + 'option_index' => static::ID, + 'label_for' => $id, + 'input_type' => 'text', + 'default_value' => $settings[ $id ] ?? '', + 'class' => 'classifai-provider-field hidden provider-scope-' . static::ID, // Important to add this. + 'description' => sprintf( + wp_kses( + // translators: 1 - link to create a Language resource. + __( 'Azure Cognitive Service Language Endpoint, create a Language resource in the Azure portal to get your key and endpoint.', 'classifai' ), + array( + 'a' => array( + 'href' => array(), + 'target' => array(), + ), + ) + ), + esc_url( 'https://portal.azure.com/#home' ) + ), + ] + ); + } + + /** + * Returns the default settings for this provider. + * + * @return array + */ + public function get_default_provider_settings(): array { + $common_settings = [ + 'api_key' => '', + 'authenticated' => false, + 'uses_prompt' => false, + ]; + + return $common_settings; + } + + /** + * Sanitize the settings for this provider. + * + * Can also be useful to verify the Provider API connection + * works as expected here, returning an error if needed. + * + * @param array $new_settings The settings array. + * @return array + */ + public function sanitize_settings( array $new_settings ): array { + $settings = $this->feature_instance->get_settings(); + + // Ensure proper validation of credentials happens here. + $new_settings[ static::ID ]['api_key'] = sanitize_text_field( $new_settings[ static::ID ]['api_key'] ?? $settings[ static::ID ]['api_key'] ); + $new_settings[ static::ID ]['authenticated'] = true; + + return $new_settings; + } + + /** + * Common entry point for all REST endpoints for this provider. + * + * All Features will end up calling the rest_endpoint_callback method for their assigned Provider. + * This method should validate the route that is being called and then call the appropriate method + * for that route. This method typically will validate we have all the requried data and if so, + * make a request to the appropriate API endpoint. + * + * @param int $post_id The Post ID we're processing. + * @param string $route_to_call The route we are processing. + * @param array $args Optional arguments to pass to the route. + * @return string|WP_Error + */ + public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '', array $args = [] ) { + if ( ! $post_id || ! get_post( $post_id ) ) { + return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate an excerpt.', 'text-domain' ) ); + } + + $route_to_call = strtolower( $route_to_call ); + $return = ''; + + // Handle all of our routes. + switch ( $route_to_call ) { + case 'test': + // Ensure this method exists. + $return = $this->generate( $post_id, $args ); + break; + } + + return $return; + } + + /** + * Returns the debug information for the provider settings. + * + * This is used to display various settings in the Site Health screen. + * Not required but useful for debugging. + * + * @return array + */ + public function get_debug_information(): array { + $settings = $this->feature_instance->get_settings(); + $debug_info = []; + + return $debug_info; + } +} diff --git a/includes/Classifai/Services/LanguageProcessing.php b/includes/Classifai/Services/LanguageProcessing.php index 436aae0e9..13420ab84 100644 --- a/includes/Classifai/Services/LanguageProcessing.php +++ b/includes/Classifai/Services/LanguageProcessing.php @@ -48,6 +48,7 @@ public static function get_service_providers(): array { 'Classifai\Providers\Azure\OpenAI', 'Classifai\Providers\AWS\AmazonPolly', 'Classifai\Providers\Azure\Embeddings', + 'Classifai\Providers\Azure\Language', ] ); } From d9e08b0f453fc5e018b4e78cd3d03012952e40fa Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Fri, 2 Aug 2024 06:51:02 -0700 Subject: [PATCH 2/8] clean up settings and add auth check --- .../Classifai/Providers/Azure/Language.php | 83 +++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php index 72e113c4d..0c7f594c8 100644 --- a/includes/Classifai/Providers/Azure/Language.php +++ b/includes/Classifai/Providers/Azure/Language.php @@ -18,6 +18,20 @@ class Language extends Provider { */ const ID = 'azure_language'; + /** + * The Provider Name. + * + * Required and should be unique. + */ + const API_VERSION = '2023-04-01'; + + /** + * Analyze Text endpoint. + * + * @var string + */ + const ANALYZE_TEXT_ENDPOINT = '/language/analyze-text/jobs'; + /** * MyProvider constructor. * @@ -36,20 +50,20 @@ public function __construct( $feature_instance = null ) { */ public function render_provider_fields() { $settings = $this->feature_instance->get_settings( static::ID ); - $id = 'endpoint_url'; $this->add_api_key_field(); + add_settings_field( - $id, + static::ID . '_endpoint_url', $args['label'] ?? esc_html__( 'Endpoint URL', 'classifai' ), [ $this->feature_instance, 'render_input' ], $this->feature_instance->get_option_name(), $this->feature_instance->get_option_name() . '_section', [ 'option_index' => static::ID, - 'label_for' => $id, + 'label_for' => 'endpoint_url', 'input_type' => 'text', - 'default_value' => $settings[ $id ] ?? '', + 'default_value' => $settings['endpoint_url'], 'class' => 'classifai-provider-field hidden provider-scope-' . static::ID, // Important to add this. 'description' => sprintf( wp_kses( @@ -76,8 +90,8 @@ public function render_provider_fields() { public function get_default_provider_settings(): array { $common_settings = [ 'api_key' => '', + 'endpoint_url' => '', 'authenticated' => false, - 'uses_prompt' => false, ]; return $common_settings; @@ -95,13 +109,68 @@ public function get_default_provider_settings(): array { public function sanitize_settings( array $new_settings ): array { $settings = $this->feature_instance->get_settings(); - // Ensure proper validation of credentials happens here. $new_settings[ static::ID ]['api_key'] = sanitize_text_field( $new_settings[ static::ID ]['api_key'] ?? $settings[ static::ID ]['api_key'] ); - $new_settings[ static::ID ]['authenticated'] = true; + $new_settings[ static::ID ]['endpoint_url'] = esc_url_raw( $new_settings[ static::ID ]['endpoint_url'] ?? $settings[ static::ID ]['endpoint_url'] ); + $new_settings[ static::ID ]['authenticated'] = false; + + if ( ! empty( $new_settings[ static::ID ]['endpoint_url'] ) && ! empty( $new_settings[ static::ID ]['api_key'] ) ) { + $new_settings[ static::ID ]['authenticated'] = $this->authenticate_credentials( + $new_settings[ static::ID ]['endpoint_url'], + $new_settings[ static::ID ]['api_key'] + ); + } + + if ( ! $new_settings[ static::ID ]['authenticated'] ) { + add_settings_error( + 'authenticated', + 400, + esc_html( 'There was an error authenticating with Azure Language Services. Please check your credentials.' ), + 'error' + ); + } return $new_settings; } + /** + * Authenticates our credentials. + * + * Performs a simple test to ensure the credentials are valid. + * + * @param string $url Endpoint URL. + * @param string $api_key Api Key. + * + * @return bool|WP_Error + */ + protected function authenticate_credentials( string $url, string $api_key ) { + $rtn = false; + + $endpoint = trailingslashit( $url ) . '/text/analytics/v3.1/languages'; + $endpoint = add_query_arg( 'api-version', static::API_VERSION, $endpoint ); + + $request = wp_remote_post( + $endpoint, + [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $api_key, + 'Content-Type' => 'application/json', + ], + 'body' => '{"documents": [{"id": "1","text": "Hello world"}]}', + ] + ); + + if ( ! is_wp_error( $request ) ) { + $response = json_decode( wp_remote_retrieve_body( $request ) ); + if ( ! empty( $response->error ) ) { + $rtn = new WP_Error( 'auth', $response->error->message ); + } else { + $rtn = true; + } + } + + return $rtn; + } + /** * Common entry point for all REST endpoints for this provider. * From 5369138429595159c91cb234b9bb906353e16e68 Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Fri, 2 Aug 2024 06:51:29 -0700 Subject: [PATCH 3/8] add debug info --- .../Classifai/Providers/Azure/Language.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php index 0c7f594c8..c4715c1b9 100644 --- a/includes/Classifai/Providers/Azure/Language.php +++ b/includes/Classifai/Providers/Azure/Language.php @@ -212,9 +212,21 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '' * @return array */ public function get_debug_information(): array { - $settings = $this->feature_instance->get_settings(); - $debug_info = []; + $settings = $this->feature_instance->get_settings(); + $provider_settings = $settings[ static::ID ]; + $debug_info = []; + + if ( $this->feature_instance instanceof ExcerptGeneration ) { + $debug_info[ __( 'Excerpt length', 'classifai' ) ] = apply_filters( 'classifai_azure_language_summary_length', 'oneSentence' ); + $debug_info[ __( 'Provider', 'classifai' ) ] = 'Azure Language Services'; + $debug_info[ __( 'Endpoint URL', 'classifai' ) ] = $provider_settings['endpoint_url']; + } - return $debug_info; + return apply_filters( + 'classifai_' . self::ID . '_debug_information', + $debug_info, + $settings, + $this->feature_instance + ); } } From 7cde3f92f9382009db655ead2310ad032dc08216 Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Fri, 2 Aug 2024 07:25:23 -0700 Subject: [PATCH 4/8] avoid confusion by disabling feature if not authenticated --- includes/Classifai/Providers/Azure/Language.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php index c4715c1b9..4d6bfab1b 100644 --- a/includes/Classifai/Providers/Azure/Language.php +++ b/includes/Classifai/Providers/Azure/Language.php @@ -127,6 +127,9 @@ public function sanitize_settings( array $new_settings ): array { esc_html( 'There was an error authenticating with Azure Language Services. Please check your credentials.' ), 'error' ); + + // disable the feature if we're unable to authenticate./ + $new_settings['status'] = 0; } return $new_settings; From dbb5135690571bb26a197887a21ca8e0e390a224 Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Fri, 2 Aug 2024 07:26:11 -0700 Subject: [PATCH 5/8] add excerpt generation --- .../Classifai/Features/ExcerptGeneration.php | 2 + .../Classifai/Providers/Azure/Language.php | 162 +++++++++++++++++- 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/includes/Classifai/Features/ExcerptGeneration.php b/includes/Classifai/Features/ExcerptGeneration.php index 431ec22cb..459502288 100644 --- a/includes/Classifai/Features/ExcerptGeneration.php +++ b/includes/Classifai/Features/ExcerptGeneration.php @@ -6,6 +6,7 @@ use Classifai\Providers\GoogleAI\GeminiAPI; use Classifai\Providers\OpenAI\ChatGPT; use Classifai\Providers\Azure\OpenAI; +use Classifai\Providers\Azure\Language; use WP_REST_Server; use WP_REST_Request; use WP_Error; @@ -45,6 +46,7 @@ public function __construct() { ChatGPT::ID => __( 'OpenAI ChatGPT', 'classifai' ), GeminiAPI::ID => __( 'Google AI (Gemini API)', 'classifai' ), OpenAI::ID => __( 'Azure OpenAI', 'classifai' ), + Language::ID => __( 'Azure Language', 'classifai' ), ]; } diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php index 4d6bfab1b..03e6a441d 100644 --- a/includes/Classifai/Providers/Azure/Language.php +++ b/includes/Classifai/Providers/Azure/Language.php @@ -8,6 +8,7 @@ namespace Classifai\Providers\Azure; use Classifai\Providers\Provider; +use Classifai\Features\ExcerptGeneration; use WP_Error; class Language extends Provider { @@ -195,17 +196,170 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '' $route_to_call = strtolower( $route_to_call ); $return = ''; - // Handle all of our routes. switch ( $route_to_call ) { - case 'test': - // Ensure this method exists. - $return = $this->generate( $post_id, $args ); + case 'excerpt': + $return = $this->generate_excerpt( $post_id, $args ); break; } return $return; } + /** + * Generate an excerpt for a given post. + * + * This service requires two API calls - one to request the summary and another to retrieve the summary. + * + * @param int $post_id The Post ID we're processing. + * @param array $args Optional arguments to pass to the route. + * @return string + */ + public function generate_excerpt( $post_id, $args ) { + $feature = new ExcerptGeneration(); + $settings = $feature->get_settings(); + $api_key = $settings[ static::ID ]['api_key']; + $endpoint_url = $settings[ static::ID ]['endpoint_url']; + $post_content = get_post_field( 'post_content', $post_id ); + + // Request the summary form the API. + $summary_request_url = $this->request_summary( $endpoint_url, $api_key, $post_content, $post_id ); + if ( is_wp_error( $summary_request_url ) ) { + return $summary_request_url; + } + + // Retrieve the Summary after the job is complete. + $summary = $this->retrieve_summary( $summary_request_url ); + + return $summary; + } + + /** + * Request the summary from the API. + * + * @param string $endpoint_url The endpoint URL. + * @param string $api_key The API key. + * @param string $post_content The post content. + * @param int $post_id The post ID. + * + * @return mixed The summary job URL or WP_Error. + */ + public function request_summary( $endpoint_url, $api_key, $post_content, $post_id ) { + $endpoint_url = add_query_arg( + 'api-version', + static::API_VERSION, + $endpoint_url . static::ANALYZE_TEXT_ENDPOINT + ); + + $body = [ + 'analysisInput' => [ + 'documents' => [ + [ + 'id' => '1', + 'language' => 'en', + 'text' => $post_content, + ], + ], + ], + 'tasks' => [ + [ + 'kind' => 'AbstractiveSummarization', + 'taskName' => 'Classifai Summarization ' . $post_id, + 'parameters' => [ + /** + * Filter the summary length. + * Possible values are 'oneSentence', 'short', 'medium', 'long'. + * Default is 'oneSentence'. + * + * @since 3.3.0 + * @hook classifai_azure_language_summary_length + * @param string $summary_length The summary length. + * @return string + */ + 'summaryLength' => apply_filters( 'classifai_azure_language_summary_length', 'oneSentence' ), + ], + ], + ], + ]; + + $request = wp_remote_post( + $endpoint_url, + [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $api_key, + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $body ), + ] + ); + + $headers = wp_remote_retrieve_headers( $request ); + + if ( ! is_wp_error( $request ) ) { + $response = json_decode( wp_remote_retrieve_body( $request ) ); + if ( ! empty( $response->error ) ) { + return new WP_Error( 'summary_request', $response->error->message ); + } elseif ( empty( $headers['operation-location'] ) ) { + return new WP_Error( 'summary_request', esc_html__( 'There was an error requesting the summary.', 'classifai' ) ); + } else { + return $headers['operation-location']; + } + } + + return $request; + } + + /** + * Get the text analysis job response + * + * The endpoint URL is returned in the 'operation-location' header of the initial request. + * The job response will return a 'status' property if the job is completed. + * If the job is not completed, wait a second and try again. + * + * @param string $url The URL to analyze. + * @see https://learn.microsoft.com/en-us/azure/ai-services/language-service/summarization/quickstart?tabs=text-summarization%2Cmacos&pivots=rest-api + * @return mixed + */ + private function retrieve_summary( $url ) { + $api_key = $this->feature_instance->get_settings( static::ID )['api_key']; + + $request = wp_remote_get( + $url, + [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $api_key, + 'Content-Type' => 'application/json', + ], + ] + ); + + $summary = ''; + + if ( ! is_wp_error( $request ) ) { + $response = json_decode( wp_remote_retrieve_body( $request ) ); + if ( ! empty( $response->error ) ) { + return new WP_Error( 'auth', $response->error->message ); + } + + while ( 'succeeded' !== $response->status ) { + sleep( .5 ); + $request = wp_remote_get( + $url, + [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $api_key, + 'Content-Type' => 'application/json', + ], + ] + ); + $response = json_decode( wp_remote_retrieve_body( $request ) ); + } + + $summary = $response->tasks->items[0]->results->documents[0]->summaries[0]->text; + } + + return $summary; + } + /** * Returns the debug information for the provider settings. * From f9e56eea5010b20aebe5640a136e7e6af3a81671 Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Mon, 5 Aug 2024 13:16:22 -0700 Subject: [PATCH 6/8] fix filter doc --- includes/Classifai/Providers/Azure/Language.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php index 03e6a441d..ada349b41 100644 --- a/includes/Classifai/Providers/Azure/Language.php +++ b/includes/Classifai/Providers/Azure/Language.php @@ -270,7 +270,7 @@ public function request_summary( $endpoint_url, $api_key, $post_content, $post_i * Possible values are 'oneSentence', 'short', 'medium', 'long'. * Default is 'oneSentence'. * - * @since 3.3.0 + * @since 3.2.0 * @hook classifai_azure_language_summary_length * @param string $summary_length The summary length. * @return string From b550389f411762cb570c8f8f2fdb476ab0860f03 Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Mon, 5 Aug 2024 17:39:53 -0700 Subject: [PATCH 7/8] add helper function to utilize vip remote get --- includes/Classifai/Helpers.php | 24 +++++++++++++++++++ .../Classifai/Providers/Azure/Language.php | 8 ++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/includes/Classifai/Helpers.php b/includes/Classifai/Helpers.php index 5968fe69b..76a6dc990 100644 --- a/includes/Classifai/Helpers.php +++ b/includes/Classifai/Helpers.php @@ -661,3 +661,27 @@ function get_classification_mode(): string { return $value; } + + +/** + * Use VIP's `vip_safe_wp_remote_get` if available, otherwise use `wp_remote_get`. + * + * @param string $url URL to fetch. + * @param array $args Optional. Request arguments. + * @return WP_Error|array The response or WP_Error on failure. + */ +function safe_wp_remote_get( string $url, array $args = [] ) { + if ( function_exists( 'vip_safe_wp_remote_get' ) ) { + return vip_safe_wp_remote_get( $url, $args ); + } + + wp_parse_args( + $args, + [ + 'timeout' => 20, // phpcs:ignore + + ] + ); + + return wp_remote_get( $url, $args ); // phpcs:ignore +} \ No newline at end of file diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php index ada349b41..3b21b93df 100644 --- a/includes/Classifai/Providers/Azure/Language.php +++ b/includes/Classifai/Providers/Azure/Language.php @@ -10,6 +10,8 @@ use Classifai\Providers\Provider; use Classifai\Features\ExcerptGeneration; use WP_Error; +use function Classifai\safe_wp_remote_get; + class Language extends Provider { /** @@ -152,7 +154,7 @@ protected function authenticate_credentials( string $url, string $api_key ) { $endpoint = trailingslashit( $url ) . '/text/analytics/v3.1/languages'; $endpoint = add_query_arg( 'api-version', static::API_VERSION, $endpoint ); - $request = wp_remote_post( + $request = safe_wp_remote_get( $endpoint, [ 'headers' => [ @@ -322,7 +324,7 @@ public function request_summary( $endpoint_url, $api_key, $post_content, $post_i private function retrieve_summary( $url ) { $api_key = $this->feature_instance->get_settings( static::ID )['api_key']; - $request = wp_remote_get( + $request = safe_wp_remote_get( $url, [ 'headers' => [ @@ -342,7 +344,7 @@ private function retrieve_summary( $url ) { while ( 'succeeded' !== $response->status ) { sleep( .5 ); - $request = wp_remote_get( + $request = safe_wp_remote_get( $url, [ 'headers' => [ From c53e387dadeaeaddae6098df65502f8de6904e6a Mon Sep 17 00:00:00 2001 From: Peter Sorensen Date: Thu, 5 Sep 2024 19:43:14 -0700 Subject: [PATCH 8/8] update filter docblock --- includes/Classifai/Providers/Azure/Language.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Classifai/Providers/Azure/Language.php b/includes/Classifai/Providers/Azure/Language.php index 3b21b93df..a04f0f1ec 100644 --- a/includes/Classifai/Providers/Azure/Language.php +++ b/includes/Classifai/Providers/Azure/Language.php @@ -274,8 +274,8 @@ public function request_summary( $endpoint_url, $api_key, $post_content, $post_i * * @since 3.2.0 * @hook classifai_azure_language_summary_length - * @param string $summary_length The summary length. - * @return string + * @param {string} $summary_length The summary length. + * @return {string} The summary length */ 'summaryLength' => apply_filters( 'classifai_azure_language_summary_length', 'oneSentence' ), ],