diff --git a/includes/Classifai/Admin/PreviewClassifierData.php b/includes/Classifai/Admin/PreviewClassifierData.php index 37f3f5cfa..7f090464c 100644 --- a/includes/Classifai/Admin/PreviewClassifierData.php +++ b/includes/Classifai/Admin/PreviewClassifierData.php @@ -20,7 +20,7 @@ public function __construct() { public function get_post_classifier_preview_data() { $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : false; - if ( ! $nonce || ! wp_verify_nonce( $nonce, 'classifai-previewer-action' ) ) { + if ( ! $nonce || ! wp_verify_nonce( $nonce, 'classifai-previewer-watson_nlu-action' ) ) { wp_send_json_error( esc_html__( 'Failed nonce check.', 'classifai' ) ); } @@ -45,7 +45,13 @@ public function get_post_classifier_preview_data() { public function get_post_search_results() { $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : false; - if ( ! $nonce || ! wp_verify_nonce( $nonce, 'classifai-previewer-action' ) ) { + if ( + ! $nonce + || ( + ! wp_verify_nonce( $nonce, 'classifai-previewer-openai_embeddings-action' ) + && ! wp_verify_nonce( $nonce, 'classifai-previewer-watson_nlu-nonce' ) + ) + ) { wp_send_json_error( esc_html__( 'Failed nonce check.', 'classifai' ) ); } diff --git a/includes/Classifai/Providers/OpenAI/EmbeddingCalculations.php b/includes/Classifai/Providers/OpenAI/EmbeddingCalculations.php index 994037c65..7d41b162e 100644 --- a/includes/Classifai/Providers/OpenAI/EmbeddingCalculations.php +++ b/includes/Classifai/Providers/OpenAI/EmbeddingCalculations.php @@ -15,11 +15,10 @@ class EmbeddingCalculations { * * @param array $source_embedding Embedding data of the source item. * @param array $compare_embedding Embedding data of the item to compare. - * @param float $threshold The threshold to use for the similarity calculation. * * @return bool|float */ - public function similarity( array $source_embedding = [], array $compare_embedding = [], $threshold = 1 ) { + public function similarity( array $source_embedding = [], array $compare_embedding = [] ) { if ( empty( $source_embedding ) || empty( $compare_embedding ) ) { return false; } @@ -58,20 +57,8 @@ function( $x ) { // Do the math. $distance = 1.0 - ( $combined_average / sqrt( $source_average * $compare_average ) ); - /** - * Filter the threshold for the similarity calculation. - * - * @since 2.5.0 - * @hook classifai_threshold - * - * @param {float} $threshold The threshold to use. - * - * @return {float} The threshold to use. - */ - $threshold = apply_filters( 'classifai_threshold', $threshold ); - - // Ensure we are within the range of 0 to 1.0 (i.e. $threshold). - return max( 0, min( abs( (float) $distance ), $threshold ) ); + // Ensure we are within the range of 0 to 1.0. + return max( 0, min( abs( (float) $distance ), 1.0 ) ); } } diff --git a/includes/Classifai/Providers/OpenAI/Embeddings.php b/includes/Classifai/Providers/OpenAI/Embeddings.php index a6450fb7b..d6415fd47 100644 --- a/includes/Classifai/Providers/OpenAI/Embeddings.php +++ b/includes/Classifai/Providers/OpenAI/Embeddings.php @@ -85,6 +85,7 @@ public function register() { add_filter( 'rest_api_init', [ $this, 'add_process_content_meta_to_rest_api' ] ); add_action( 'add_meta_boxes', [ $this, 'add_metabox' ] ); add_action( 'save_post', [ $this, 'save_metabox' ] ); + add_action( 'wp_ajax_get_post_classifier_embeddings_preview_data', array( $this, 'get_post_classifier_embeddings_preview_data' ) ); } } @@ -456,12 +457,36 @@ public function supported_taxonomies() { return apply_filters( 'classifai_openai_embeddings_taxonomies', $this->get_supported_taxonomies() ); } + /** + * Get the data to preview terms. + * + * @since 2.5.0 + * + * @return array + */ + public function get_post_classifier_embeddings_preview_data() { + $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : false; + + if ( ! $nonce || ! wp_verify_nonce( $nonce, 'classifai-previewer-openai_embeddings-action' ) ) { + wp_send_json_error( esc_html__( 'Failed nonce check.', 'classifai' ) ); + } + + $post_id = filter_input( INPUT_POST, 'post_id', FILTER_SANITIZE_NUMBER_INT ); + + $embeddings_terms = $this->generate_embeddings_for_post( $post_id, true ); + + return wp_send_json_success( $embeddings_terms ); + } + /** * Trigger embedding generation for content being saved. * - * @param int $post_id ID of post being saved. + * @param int $post_id ID of post being saved. + * @param bool $dryrun Whether to run the process or just return the data. + * + * @return array|WP_Error */ - public function generate_embeddings_for_post( $post_id ) { + public function generate_embeddings_for_post( $post_id, $dryrun = false ) { // Don't run on autosaves. if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; @@ -476,14 +501,17 @@ public function generate_embeddings_for_post( $post_id ) { // Only run on supported post types and statuses. if ( - ! in_array( $post->post_type, $this->supported_post_types(), true ) || - ! in_array( $post->post_status, $this->supported_post_statuses(), true ) + ! $dryrun + && ( + ! in_array( $post->post_type, $this->supported_post_types(), true ) || + ! in_array( $post->post_status, $this->supported_post_statuses(), true ) + ) ) { return; } // Don't run if turned off for this particular post. - if ( 'no' === get_post_meta( $post_id, '_classifai_process_content', true ) ) { + if ( 'no' === get_post_meta( $post_id, '_classifai_process_content', true ) && ! $dryrun ) { return; } @@ -491,8 +519,12 @@ public function generate_embeddings_for_post( $post_id ) { // Add terms to this item based on embedding data. if ( $embeddings && ! is_wp_error( $embeddings ) ) { - update_post_meta( $post_id, 'classifai_openai_embeddings', array_map( 'sanitize_text_field', $embeddings ) ); - $this->set_terms( $post_id, $embeddings ); + if ( $dryrun ) { + return $this->get_terms( $embeddings ); + } else { + update_post_meta( $post_id, 'classifai_openai_embeddings', array_map( 'sanitize_text_field', $embeddings ) ); + return $this->set_terms( $post_id, $embeddings ); + } } } @@ -513,6 +545,102 @@ private function set_terms( int $post_id = 0, array $embedding = [] ) { $settings = $this->get_settings(); $number_to_add = $settings['number'] ?? 1; + $embedding_similarity = $this->get_embeddings_similarity( $embedding ); + + if ( empty( $embedding_similarity ) ) { + return; + } + + // Set terms based on similarity. + foreach ( $embedding_similarity as $tax => $terms ) { + // Sort embeddings from lowest to highest. + asort( $terms ); + + // Only add the number of terms specified in settings. + if ( count( $terms ) > $number_to_add ) { + $terms = array_slice( $terms, 0, $number_to_add, true ); + } + + wp_set_object_terms( $post_id, array_map( 'absint', array_keys( $terms ) ), $tax, false ); + } + } + + /** + * Get the terms of a post based on embeddings. + * + * @param array $embedding Embedding data. + * + * @return array|WP_Error + */ + private function get_terms( array $embedding = [] ) { + if ( empty( $embedding ) ) { + return new WP_Error( 'data_required', esc_html__( 'Valid embedding data is required to get terms.', 'classifai' ) ); + } + + $settings = $this->get_settings(); + $number_to_add = $settings['number'] ?? 1; + $embedding_similarity = $this->get_embeddings_similarity( $embedding, false ); + + if ( empty( $embedding_similarity ) ) { + return; + } + + // Set terms based on similarity. + $index = 0; + $result = []; + + foreach ( $embedding_similarity as $tax => $terms ) { + // Get the taxonomy name. + $taxonomy = get_taxonomy( $tax ); + $tax_name = $taxonomy->labels->singular_name; + + // Sort embeddings from lowest to highest. + asort( $terms ); + + // Return the terms. + $result[ $index ] = new \stdClass(); + + $result[ $index ]->{$tax_name} = []; + + $term_added = 0; + foreach ( $terms as $term_id => $similarity ) { + // Stop if we have added the number of terms specified in settings. + if ( $number_to_add <= $term_added ) { + break; + } + + // Convert $similarity to percentage. + $similarity = round( ( 1 - $similarity ), 10 ); + + $result[ $index ]->{$tax_name}[] = [// phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found + 'label' => get_term( $term_id )->name, + 'score' => $similarity, + ]; + $term_added++; + } + + // Only add the number of terms specified in settings. + if ( count( $terms ) > $number_to_add ) { + $terms = array_slice( $terms, 0, $number_to_add, true ); + } + + $index++; + } + + return $result; + } + + /** + * Get the similarity between an embedding and all terms. + * + * @since 2.5.0 + * + * @param array $embedding Embedding data. + * @param bool $consider_threshold Whether to consider the threshold setting. + * + * @return array + */ + private function get_embeddings_similarity( $embedding, $consider_threshold = true ) { $embedding_similarity = []; $taxonomies = $this->supported_taxonomies(); $calculations = new EmbeddingCalculations(); @@ -544,30 +672,15 @@ private function set_terms( int $post_id = 0, array $embedding = [] ) { $term_embedding = get_term_meta( $term_id, 'classifai_openai_embeddings', true ); if ( $term_embedding ) { - $similarity = $calculations->similarity( $embedding, $term_embedding, $threshold ); - if ( false !== $similarity ) { + $similarity = $calculations->similarity( $embedding, $term_embedding ); + if ( false !== $similarity && ( ! $consider_threshold || $similarity <= $threshold ) ) { $embedding_similarity[ $tax ][ $term_id ] = $similarity; } } } } - if ( empty( $embedding_similarity ) ) { - return; - } - - // Set terms based on similarity. - foreach ( $embedding_similarity as $tax => $terms ) { - // Sort embeddings from lowest to highest. - asort( $terms ); - - // Only add the number of terms specified in settings. - if ( count( $terms ) > $number_to_add ) { - $terms = array_slice( $terms, 0, $number_to_add, true ); - } - - wp_set_object_terms( $post_id, array_map( 'absint', array_keys( $terms ) ), $tax, false ); - } + return $embedding_similarity; } /** diff --git a/includes/Classifai/Services/Service.php b/includes/Classifai/Services/Service.php index b5d9cd331..bc8f1a4ad 100644 --- a/includes/Classifai/Services/Service.php +++ b/includes/Classifai/Services/Service.php @@ -151,8 +151,17 @@ public function render_settings_page() { provider_classes ?? [], 'Natural Language Understanding' ); - - if ( ! is_wp_error( $provider ) && ! empty( $provider->can_register() ) && $provider->is_feature_enabled( 'content_classification' ) ) : + if ( 'openai_embeddings' === $active_tab ) { + $provider = find_provider_class( $this->provider_classes ?? [], 'Embeddings' ); + } + + if ( + ! is_wp_error( $provider ) + && ! empty( + $provider->can_register() + && ( $provider->is_feature_enabled( 'content_classification' ) || $provider->is_feature_enabled( 'classification' ) ) + ) + ) : ?>
- +

- +
- $feature ) : ?> -
-
-
- -
+ $feature ) : + ?> +
+
+
+ +
diff --git a/src/js/language-processing.js b/src/js/language-processing.js index 3ea8dd16a..43d7e568d 100644 --- a/src/js/language-processing.js +++ b/src/js/language-processing.js @@ -2,193 +2,306 @@ import Choices from 'choices.js'; import '../scss/language-processing.scss'; ( () => { - const nonceElement = document.getElementById( 'classifai-previewer-nonce' ); - if ( ! nonceElement ) { + let featureStatuses = {}; + + const nonceElementNLU = document.getElementById( + 'classifai-previewer-watson_nlu-nonce' + ); + + const nonceElementEmbeddings = document.getElementById( + 'classifai-previewer-openai_embeddings-nonce' + ); + + if ( ! nonceElementNLU && ! nonceElementEmbeddings ) { return; } - /** Previewer nonce. */ - const previewerNonce = nonceElement.value; - - /** Feature statuses. */ - const featureStatuses = { - categoriesStatus: document.getElementById( - 'classifai-settings-category' - ).checked, - keywordsStatus: document.getElementById( 'classifai-settings-keyword' ) - .checked, - entitiesStatus: document.getElementById( 'classifai-settings-entity' ) - .checked, - conceptsStatus: document.getElementById( 'classifai-settings-concept' ) - .checked, - }; + const previewWatson = () => { + if ( ! nonceElementNLU ) { + return; + } - const plurals = { - category: 'categories', - keyword: 'keywords', - entity: 'entities', - concept: 'concepts', - }; + const getClassifierDataBtn = document.getElementById( + 'get-classifier-preview-data-btn' + ); + getClassifierDataBtn.addEventListener( 'click', showPreviewWatson ); + + /** Previewer nonce. */ + const previewerNonce = nonceElementNLU.value; + + /** Feature statuses. */ + featureStatuses = { + categoriesStatus: document.getElementById( + 'classifai-settings-category' + ).checked, + keywordsStatus: document.getElementById( + 'classifai-settings-keyword' + ).checked, + entitiesStatus: document.getElementById( + 'classifai-settings-entity' + ).checked, + conceptsStatus: document.getElementById( + 'classifai-settings-concept' + ).checked, + }; - document - .querySelectorAll( - '#classifai-settings-category, #classifai-settings-keyword, #classifai-settings-entity, #classifai-settings-concept' - ) - .forEach( ( item ) => { - item.addEventListener( 'change', ( e ) => { - if ( 'classifai-settings-category' === e.target.id ) { - featureStatuses.categoriesStatus = e.target.checked; - } + const plurals = { + category: 'categories', + keyword: 'keywords', + entity: 'entities', + concept: 'concepts', + }; - if ( 'classifai-settings-keyword' === e.target.id ) { - featureStatuses.keywordsStatus = e.target.checked; - } + document + .querySelectorAll( + '#classifai-settings-category, #classifai-settings-keyword, #classifai-settings-entity, #classifai-settings-concept' + ) + .forEach( ( item ) => { + item.addEventListener( 'change', ( e ) => { + if ( 'classifai-settings-category' === e.target.id ) { + featureStatuses.categoriesStatus = e.target.checked; + } - if ( 'classifai-settings-entity' === e.target.id ) { - featureStatuses.entitiesStatus = e.target.checked; - } + if ( 'classifai-settings-keyword' === e.target.id ) { + featureStatuses.keywordsStatus = e.target.checked; + } - if ( 'classifai-settings-concept' === e.target.id ) { - featureStatuses.conceptsStatus = e.target.checked; - } + if ( 'classifai-settings-entity' === e.target.id ) { + featureStatuses.entitiesStatus = e.target.checked; + } - const taxType = e.target.id.split( '-' ).at( -1 ); + if ( 'classifai-settings-concept' === e.target.id ) { + featureStatuses.conceptsStatus = e.target.checked; + } - if ( e.target.checked ) { - document - .querySelector( `.tax-row--${ plurals[ taxType ] }` ) - .classList.remove( 'tax-row--hide' ); - } else { - document - .querySelector( `.tax-row--${ plurals[ taxType ] }` ) - .classList.add( 'tax-row--hide' ); - } + const taxType = e.target.id.split( '-' ).at( -1 ); + + if ( e.target.checked ) { + document + .querySelector( + `.tax-row--${ plurals[ taxType ] }` + ) + .classList.remove( 'tax-row--hide' ); + } else { + document + .querySelector( + `.tax-row--${ plurals[ taxType ] }` + ) + .classList.add( 'tax-row--hide' ); + } + } ); } ); - } ); - /** - * Live preview features. - * - * @param {Object} e The event object. - */ - function showPreview( e ) { - /** Category thresholds. */ - const categoryThreshold = Number( - document.querySelector( '#classifai-settings-category_threshold' ) - .value - ); - const keywordThreshold = Number( - document.querySelector( '#classifai-settings-keyword_threshold' ) - .value - ); - const entityThreshold = Number( - document.querySelector( '#classifai-settings-entity_threshold' ) - .value - ); - const conceptThreshold = Number( - document.querySelector( '#classifai-settings-concept_threshold' ) - .value - ); + /** + * Live preview features. + * + * @param {Object} e The event object. + */ + function showPreviewWatson( e ) { + /** Category thresholds. */ + const categoryThreshold = Number( + document.querySelector( + '#classifai-settings-category_threshold' + ).value + ); + const keywordThreshold = Number( + document.querySelector( + '#classifai-settings-keyword_threshold' + ).value + ); + const entityThreshold = Number( + document.querySelector( '#classifai-settings-entity_threshold' ) + .value + ); + const conceptThreshold = Number( + document.querySelector( + '#classifai-settings-concept_threshold' + ).value + ); - const postId = document.getElementById( - 'classifai-preview-post-selector' - ).value; + const postId = document.getElementById( + 'classifai-preview-post-selector' + ).value; - const previewWrapper = document.getElementById( - 'classifai-post-preview-wrapper' - ); - const thresholds = { - categories: categoryThreshold, - keywords: keywordThreshold, - entities: entityThreshold, - concepts: conceptThreshold, - }; + const previewWrapper = document.getElementById( + 'classifai-post-preview-wrapper' + ); + const thresholds = { + categories: categoryThreshold, + keywords: keywordThreshold, + entities: entityThreshold, + concepts: conceptThreshold, + }; - e.target - .closest( '.button' ) - .classList.add( 'get-classifier-preview-data-btn--loading' ); + e.target + .closest( '.button' ) + .classList.add( 'get-classifier-preview-data-btn--loading' ); - const formData = new FormData(); - formData.append( 'action', 'get_post_classifier_preview_data' ); - formData.append( 'post_id', postId ); - formData.append( 'nonce', previewerNonce ); + const formData = new FormData(); + formData.append( 'action', 'get_post_classifier_preview_data' ); + formData.append( 'post_id', postId ); + formData.append( 'nonce', previewerNonce ); - fetch( `${ ajaxurl }`, { - method: 'POST', - body: formData, - } ) - .then( ( response ) => { - return response.json(); + fetch( `${ ajaxurl }`, { + method: 'POST', + body: formData, } ) - .then( ( data ) => { - if ( ! data.success ) { + .then( ( response ) => { + return response.json(); + } ) + .then( ( data ) => { + if ( ! data.success ) { + previewWrapper.style.display = 'block'; + previewWrapper.innerHTML = data.data; + e.target + .closest( '.button' ) + .classList.remove( + 'get-classifier-preview-data-btn--loading' + ); + return; + } + + const { + data: { + categories = [], + concepts = [], + entities = [], + keywords = [], + }, + } = data; + + const dataToFilter = { + categories, + keywords, + entities, + concepts, + }; + + const filteredItems = filterByScoreOrRelevance( + dataToFilter, + thresholds + ); + const htmlData = buildPreviewUI( filteredItems ); previewWrapper.style.display = 'block'; - previewWrapper.innerHTML = data.data; + previewWrapper.innerHTML = htmlData; + e.target .closest( '.button' ) .classList.remove( 'get-classifier-preview-data-btn--loading' ); - return; - } - - const { - data: { - categories = [], - concepts = [], - entities = [], - keywords = [], - }, - } = data; - - const dataToFilter = { - categories, - keywords, - entities, - concepts, - }; - - const filteredItems = filterByScoreOrRelevance( - dataToFilter, - thresholds - ); - const htmlData = buildPreviewUI( filteredItems ); - previewWrapper.style.display = 'block'; - previewWrapper.innerHTML = htmlData; - - e.target - .closest( '.button' ) - .classList.remove( - 'get-classifier-preview-data-btn--loading' - ); - } ); - } + } ); + } + + /** + * Filters response data depending on the threshold value. + * + * @param {Object} data Response data from NLU. + * @param {Object} thresholds Object containing threshold values for various taxnomy types. + * @return {Array} Sorted data. + */ + function filterByScoreOrRelevance( data = {}, thresholds ) { + const filteredItems = Object.keys( data ).map( ( key ) => ( { + [ key ]: data[ key ].filter( ( item ) => { + if ( item?.score && item.score * 100 > thresholds[ key ] ) { + return item; + } else if ( + item?.relevance && + item.relevance * 100 > thresholds[ key ] + ) { + return item; + } - /** - * Filters response data depending on the threshold value. - * - * @param {Object} data Response data from NLU. - * @param {Object} thresholds Object containing threshold values for various taxnomy types. - * @return {Array} Sorted data. - */ - function filterByScoreOrRelevance( data = {}, thresholds ) { - const filteredItems = Object.keys( data ).map( ( key ) => ( { - [ key ]: data[ key ].filter( ( item ) => { - if ( item?.score && item.score * 100 > thresholds[ key ] ) { - return item; - } else if ( - item?.relevance && - item.relevance * 100 > thresholds[ key ] - ) { return item; - } + } ), + } ) ); - return item; - } ), - } ) ); + return filteredItems; + } + }; + previewWatson(); - return filteredItems; - } + const previewEmbeddings = () => { + if ( ! nonceElementEmbeddings ) { + return; + } + + const getClassifierDataBtn = document.getElementById( + 'get-classifier-preview-data-btn' + ); + getClassifierDataBtn.addEventListener( 'click', showPreviewEmeddings ); + + /** Previewer nonce. */ + const previewerNonce = nonceElementEmbeddings.value; + + /** + * Live preview features. + * + * @param {Object} e The event object. + */ + function showPreviewEmeddings( e ) { + const postId = document.getElementById( + 'classifai-preview-post-selector' + ).value; + + const previewWrapper = document.getElementById( + 'classifai-post-preview-wrapper' + ); + + // clear previewWrapper. + previewWrapper.innerHTML = ''; + + e.target + .closest( '.button' ) + .classList.add( 'get-classifier-preview-data-btn--loading' ); + + const formData = new FormData(); + formData.append( + 'action', + 'get_post_classifier_embeddings_preview_data' + ); + formData.append( 'post_id', postId ); + formData.append( 'nonce', previewerNonce ); + + fetch( `${ ajaxurl }`, { + method: 'POST', + body: formData, + } ) + .then( ( response ) => { + return response.json(); + } ) + .then( ( data ) => { + if ( ! data.success ) { + previewWrapper.style.display = 'block'; + previewWrapper.innerHTML = data.data; + e.target + .closest( '.button' ) + .classList.remove( + 'get-classifier-preview-data-btn--loading' + ); + return; + } + + const htmlData = buildPreviewUI( data.data ); + previewWrapper.style.display = 'block'; + previewWrapper.innerHTML = htmlData; + + // remove all .tax-row--hide + document + .querySelectorAll( '.tax-row--hide' ) + .forEach( ( item ) => { + item.classList.remove( 'tax-row--hide' ); + } ); + + e.target + .closest( '.button' ) + .classList.remove( + 'get-classifier-preview-data-btn--loading' + ); + } ); + } + }; + previewEmbeddings(); /** * Builds user readable HTML data from the response by NLU. @@ -198,6 +311,11 @@ import '../scss/language-processing.scss'; */ function buildPreviewUI( filteredItems = [] ) { let htmlData = ''; + // check if filteredItems.forEach type is function + if ( ! filteredItems.forEach ) { + return ''; + } + filteredItems.forEach( ( obj ) => { Object.keys( obj ).forEach( ( prop ) => { htmlData += `