From 837f04fac583bc0e2c664eda01eedc3c8ea38efa Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 11:42:11 -0400 Subject: [PATCH 01/72] Register new Contextual Tagging experiment. Create editor UI and unit tests. --- .../Contextual_Tagging/Contextual_Tagging.php | 489 ++++++++++++++++++ .../Contextual_Tagging/system-instruction.php | 42 ++ includes/Experiment_Loader.php | 1 + .../Contextual_Tagging/Contextual_Tagging.php | 236 +++++++++ .../components/CategoryPanelWrapper.tsx | 118 +++++ .../components/SuggestionPanel.tsx | 153 ++++++ .../components/TagPanelWrapper.tsx | 118 +++++ .../components/useContextualTagging.ts | 250 +++++++++ src/experiments/contextual-tagging/index.tsx | 24 + src/experiments/contextual-tagging/types.ts | 41 ++ .../Abilities/Contextual_TaggingTest.php | 403 +++++++++++++++ .../Contextual_TaggingTest.php | 121 +++++ webpack.config.js | 5 + 13 files changed, 2001 insertions(+) create mode 100644 includes/Abilities/Contextual_Tagging/Contextual_Tagging.php create mode 100644 includes/Abilities/Contextual_Tagging/system-instruction.php create mode 100644 includes/Experiments/Contextual_Tagging/Contextual_Tagging.php create mode 100644 src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx create mode 100644 src/experiments/contextual-tagging/components/SuggestionPanel.tsx create mode 100644 src/experiments/contextual-tagging/components/TagPanelWrapper.tsx create mode 100644 src/experiments/contextual-tagging/components/useContextualTagging.ts create mode 100644 src/experiments/contextual-tagging/index.tsx create mode 100644 src/experiments/contextual-tagging/types.ts create mode 100644 tests/Integration/Includes/Abilities/Contextual_TaggingTest.php create mode 100644 tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php diff --git a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php new file mode 100644 index 000000000..822eafbdf --- /dev/null +++ b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php @@ -0,0 +1,489 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Content to generate taxonomy suggestions for.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Content from this post will be used to generate taxonomy suggestions. This overrides the content parameter if both are provided.', 'ai' ), + ), + 'taxonomy' => array( + 'type' => 'string', + 'default' => 'post_tag', + 'sanitize_callback' => 'sanitize_key', + 'description' => esc_html__( 'The taxonomy to generate suggestions for (e.g., post_tag, category).', 'ai' ), + ), + 'strategy' => array( + 'type' => 'string', + 'default' => 'existing_only', + 'sanitize_callback' => 'sanitize_key', + 'description' => esc_html__( 'The suggestion strategy: existing_only or allow_new.', 'ai' ), + ), + 'max_suggestions' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 10, + 'default' => self::SUGGESTIONS_DEFAULT, + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Maximum number of suggestions to generate.', 'ai' ), + ), + ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.6.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'suggestions' => array( + 'type' => 'array', + 'description' => esc_html__( 'Generated taxonomy term suggestions.', 'ai' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'term' => array( + 'type' => 'string', + 'description' => esc_html__( 'The suggested term name.', 'ai' ), + ), + 'confidence' => array( + 'type' => 'number', + 'description' => esc_html__( 'Confidence score between 0 and 1.', 'ai' ), + ), + 'is_new' => array( + 'type' => 'boolean', + 'description' => esc_html__( 'Whether this is a new term or an existing one.', 'ai' ), + ), + 'parent' => array( + 'type' => 'string', + 'description' => esc_html__( 'Parent term name for hierarchical taxonomies.', 'ai' ), + ), + ), + ), + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @since 0.6.0 + * + * @param mixed $input The input arguments to the ability. + * @return array{suggestions: array}|\WP_Error The result of the ability execution, or a WP_Error on failure. + */ + protected function execute_callback( $input ) { + // Default arguments. + $args = wp_parse_args( + $input, + array( + 'content' => null, + 'post_id' => null, + 'taxonomy' => 'post_tag', + 'strategy' => 'existing_only', + 'max_suggestions' => self::SUGGESTIONS_DEFAULT, + ), + ); + + // Validate taxonomy. + if ( ! taxonomy_exists( $args['taxonomy'] ) ) { + return new WP_Error( + 'invalid_taxonomy', + /* translators: %s: Taxonomy name. */ + sprintf( esc_html__( 'Taxonomy "%s" does not exist.', 'ai' ), sanitize_key( $args['taxonomy'] ) ) + ); + } + + // If a post ID is provided, ensure the post exists before using its content. + if ( $args['post_id'] ) { + $post = get_post( (int) $args['post_id'] ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + // Get the post context. + $context = get_post_context( (int) $args['post_id'] ); + + // Default to the passed in content if it exists. + if ( $args['content'] ) { + $context['content'] = normalize_content( $args['content'] ); + } + } else { + $context = array( + 'content' => normalize_content( $args['content'] ?? '' ), + ); + } + + // If we have no content, return an error. + if ( empty( $context['content'] ) ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'Content is required to generate taxonomy suggestions.', 'ai' ) + ); + } + + // Generate the suggestions. + $result = $this->generate_suggestions( + $context, + $args['taxonomy'], + $args['strategy'], + (int) $args['max_suggestions'] + ); + + // If we have an error, return it. + if ( is_wp_error( $result ) ) { + return $result; + } + + // If we have no results, return an error. + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No taxonomy suggestions were generated.', 'ai' ) + ); + } + + return array( + 'suggestions' => $result, + ); + } + + /** + * Returns the permission callback of the ability. + * + * @since 0.6.0 + * + * @param mixed $args The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + protected function permission_callback( $args ) { + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : null; + + if ( $post_id ) { + $post = get_post( $args['post_id'] ); + + // Ensure the post exists. + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + // Ensure the user has permission to edit this particular post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate taxonomy suggestions for this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type = get_post_type( $post_id ); + + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + // Ensure the user has permission to edit posts in general. + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate taxonomy suggestions.', 'ai' ) + ); + } + + return true; + } + + /** + * Returns the meta of the ability. + * + * @since 0.6.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generates taxonomy term suggestions from the given content. + * + * @since 0.6.0 + * + * @param string|array $context The context to generate suggestions from. + * @param string $taxonomy The taxonomy to suggest terms for. + * @param string $strategy The suggestion strategy. + * @param int $max_suggestions The maximum number of suggestions. + * @return array|\WP_Error The generated suggestions, or a WP_Error if there was an error. + */ + protected function generate_suggestions( $context, string $taxonomy, string $strategy, int $max_suggestions ) { + // Convert the context to a string if it's an array. + if ( is_array( $context ) ) { + $context = implode( + "\n", + array_map( + static function ( $key, $value ) { + return sprintf( + '%s: %s', + ucwords( str_replace( '_', ' ', $key ) ), + $value + ); + }, + array_keys( $context ), + $context + ) + ); + } + + // Fetch existing terms for the taxonomy. + $existing_terms = $this->get_existing_terms( $taxonomy ); + + // Build strategy instruction. + $strategy_instruction = $this->build_strategy_instruction( $strategy ); + + // Build existing terms instruction. + $existing_terms_instruction = $this->build_existing_terms_instruction( $existing_terms, $strategy ); + + // Get the taxonomy label for the prompt. + $taxonomy_label = $this->get_taxonomy_label( $taxonomy ); + + // Generate the suggestions using the AI client. + $result = wp_ai_client_prompt( '"""' . $context . '"""' ) + ->using_system_instruction( + $this->get_system_instruction( + null, + array( + 'strategy' => $strategy_instruction, + 'max_suggestions' => $max_suggestions, + 'taxonomy' => $taxonomy_label, + 'existing_terms' => $existing_terms_instruction, + ) + ) + ) + ->using_temperature( 0.5 ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Parse the JSON response. + return $this->parse_suggestions( $result, $existing_terms, $max_suggestions ); + } + + /** + * Gets existing terms for a taxonomy. + * + * @since 0.6.0 + * + * @param string $taxonomy The taxonomy to get terms for. + * @return array List of existing term names. + */ + protected function get_existing_terms( string $taxonomy ): array { + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'fields' => 'names', + ) + ); + + if ( is_wp_error( $terms ) ) { + return array(); + } + + return (array) $terms; + } + + /** + * Builds the strategy instruction for the system prompt. + * + * @since 0.6.0 + * + * @param string $strategy The suggestion strategy. + * @return string The strategy instruction text. + */ + protected function build_strategy_instruction( string $strategy ): string { + if ( 'existing_only' === $strategy ) { + return '- IMPORTANT: Only suggest terms that already exist on the site. Set "is_new" to false for all suggestions. Do not invent new terms.'; + } + + return '- You may suggest new terms if no good existing match exists. Set "is_new" to true for new terms and false for existing terms. Prefer existing terms when possible.'; + } + + /** + * Builds the existing terms instruction for the system prompt. + * + * @since 0.6.0 + * + * @param array $existing_terms The existing terms. + * @param string $strategy The suggestion strategy. + * @return string The existing terms instruction text. + */ + protected function build_existing_terms_instruction( array $existing_terms, string $strategy ): string { + if ( empty( $existing_terms ) ) { + if ( 'existing_only' === $strategy ) { + return '- No existing terms are available. Return an empty array.'; + } + + return '- No existing terms are available. You may suggest new terms.'; + } + + return sprintf( + "- Existing terms on the site: %s\n- Prioritize selecting from these existing terms.", + implode( ', ', $existing_terms ) + ); + } + + /** + * Gets a human-readable label for the taxonomy. + * + * @since 0.6.0 + * + * @param string $taxonomy The taxonomy slug. + * @return string The taxonomy label. + */ + protected function get_taxonomy_label( string $taxonomy ): string { + $taxonomy_obj = get_taxonomy( $taxonomy ); + + if ( $taxonomy_obj ) { + return strtolower( $taxonomy_obj->labels->name ); + } + + return $taxonomy; + } + + /** + * Parses the AI response into structured suggestions. + * + * @since 0.6.0 + * + * @param string $response The raw AI response. + * @param array $existing_terms List of existing term names. + * @param int $max_suggestions The maximum number of suggestions. + * @return array|\WP_Error Parsed suggestions or error. + */ + protected function parse_suggestions( string $response, array $existing_terms, int $max_suggestions ) { + // Strip any markdown code fences the model may have included. + $response = trim( $response ); + $response = preg_replace( '/^```(?:json)?\s*/i', '', $response ) ?? $response; + $response = preg_replace( '/\s*```$/', '', $response ) ?? $response; + $response = trim( $response ); + + $decoded = json_decode( $response, true ); + + if ( ! is_array( $decoded ) ) { + return new WP_Error( + 'invalid_response', + esc_html__( 'Could not parse AI response as valid suggestions.', 'ai' ) + ); + } + + $existing_terms_lower = array_map( 'strtolower', $existing_terms ); + $suggestions = array(); + + foreach ( $decoded as $item ) { + if ( ! is_array( $item ) || empty( $item['term'] ) ) { + continue; + } + + $term = sanitize_text_field( trim( $item['term'] ) ); + $confidence = isset( $item['confidence'] ) ? (float) $item['confidence'] : 0.5; + $is_new = ! in_array( strtolower( $term ), $existing_terms_lower, true ); + + $suggestion = array( + 'term' => $term, + 'confidence' => max( 0.0, min( 1.0, $confidence ) ), + 'is_new' => $is_new, + ); + + if ( ! empty( $item['parent'] ) ) { + $suggestion['parent'] = sanitize_text_field( trim( $item['parent'] ) ); + } + + $suggestions[] = $suggestion; + } + + // Sort by confidence descending. + usort( + $suggestions, + static function ( $a, $b ) { + return $b['confidence'] <=> $a['confidence']; + } + ); + + // Limit to max suggestions. + return array_slice( $suggestions, 0, $max_suggestions ); + } +} diff --git a/includes/Abilities/Contextual_Tagging/system-instruction.php b/includes/Abilities/Contextual_Tagging/system-instruction.php new file mode 100644 index 000000000..c289b5fc6 --- /dev/null +++ b/includes/Abilities/Contextual_Tagging/system-instruction.php @@ -0,0 +1,42 @@ + 'contextual-tagging', + 'label' => __( 'Contextual Tagging', 'ai' ), + 'description' => __( 'AI-powered suggestions for post tags and categories based on content analysis.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + * + * @since 0.6.0 + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers any needed abilities. + * + * @since 0.6.0 + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Contextual_Tagging_Ability::class, + ), + ); + } + + /** + * Enqueues and localizes the admin script. + * + * @since 0.6.0 + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + // Load asset in new post and edit post screens only. + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + $screen = get_current_screen(); + + // Load the assets only if the post type supports editor and is not an attachment. + if ( + ! $screen || + ! post_type_supports( $screen->post_type, 'editor' ) || + in_array( $screen->post_type, array( 'attachment' ), true ) + ) { + return; + } + + Asset_Loader::enqueue_script( 'contextual_tagging', 'experiments/contextual-tagging' ); + Asset_Loader::localize_script( + 'contextual_tagging', + 'ContextualTaggingData', + array( + 'enabled' => $this->is_enabled(), + 'strategy' => get_option( $this->get_field_option_name( 'strategy' ), self::STRATEGY_EXISTING_ONLY ), + 'maxSuggestions' => (int) get_option( $this->get_field_option_name( 'max_suggestions' ), self::DEFAULT_MAX_SUGGESTIONS ), + ) + ); + } + + /** + * Registers experiment-specific settings. + * + * @since 0.6.0 + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'strategy' ), + array( + 'type' => 'string', + 'default' => self::STRATEGY_EXISTING_ONLY, + 'sanitize_callback' => array( $this, 'sanitize_strategy' ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'max_suggestions' ), + array( + 'type' => 'integer', + 'default' => self::DEFAULT_MAX_SUGGESTIONS, + 'sanitize_callback' => array( $this, 'sanitize_max_suggestions' ), + ) + ); + } + + /** + * Renders experiment-specific settings fields. + * + * @since 0.6.0 + */ + public function render_settings_fields(): void { + $strategy_option = $this->get_field_option_name( 'strategy' ); + $max_suggestions_option = $this->get_field_option_name( 'max_suggestions' ); + $current_strategy = get_option( $strategy_option, self::STRATEGY_EXISTING_ONLY ); + $current_max = get_option( $max_suggestions_option, self::DEFAULT_MAX_SUGGESTIONS ); + ?> +
+ +

+ + +

+

+ + +

+
+ ( + null + ); + + useEffect( () => { + const findAndAttach = (): boolean => { + // Don't create duplicate containers. + if ( document.getElementById( CONTAINER_ID ) ) { + return true; + } + + // Find the Categories panel by its toggle button text. + const categoriesPanel = findPanelByTitle( 'Categories' ); + + if ( ! categoriesPanel ) { + return false; + } + + // Create and inject our container at the end of the panel. + const el = document.createElement( 'div' ); + el.id = CONTAINER_ID; + categoriesPanel.appendChild( el ); + setContainer( el ); + return true; + }; + + // Try immediately. + if ( findAndAttach() ) { + return; + } + + // Observe for the panel appearing. + const observer = new MutationObserver( () => { + if ( findAndAttach() ) { + observer.disconnect(); + } + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + return () => { + observer.disconnect(); + const el = document.getElementById( CONTAINER_ID ); + if ( el ) { + el.remove(); + } + }; + }, [] ); + + useEffect( () => { + if ( ! container ) { + return; + } + + const root = createRoot( container ); + root.render( ); + + return () => { + root.unmount(); + }; + }, [ container ] ); + + return null; +} diff --git a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx new file mode 100644 index 000000000..963e5cd97 --- /dev/null +++ b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx @@ -0,0 +1,153 @@ +/** + * Suggestion panel component for displaying AI-generated taxonomy suggestions. + */ + +/** + * WordPress dependencies + */ +import { Button, Spinner } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { close as closeIcon, update } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { useContextualTagging } from './useContextualTagging'; +import type { TagSuggestion } from '../types'; + +interface SuggestionPanelProps { + taxonomy: string; +} + +/** + * SuggestionPanel component. + * + * Displays a button to generate suggestions and renders suggestion pills + * that can be accepted or dismissed. + * + * @param props Component props. + * @return The suggestion panel component. + */ +export default function SuggestionPanel( { + taxonomy, +}: SuggestionPanelProps ): JSX.Element | null { + const { + isGenerating, + suggestions, + hasEnoughContent, + handleGenerate, + handleAccept, + handleDismiss, + handleDismissAll, + } = useContextualTagging( taxonomy ); + + const taxonomyLabel = + taxonomy === 'category' + ? __( 'Categories', 'ai' ) + : __( 'Tags', 'ai' ); + + const hasSuggestions = suggestions.length > 0; + + return ( +
+ { ! hasSuggestions && ( + + ) } + + { ! hasEnoughContent && ! hasSuggestions && ( +

+ { __( + 'Add more content to enable AI suggestions (approximately 150 words).', + 'ai' + ) } +

+ ) } + + { isGenerating && ( +
+ +
+ ) } + + { hasSuggestions && ( +
+
+ { suggestions.map( ( suggestion: TagSuggestion ) => ( + + +
+
+ + +
+
+ ) } +
+ ); +} diff --git a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx new file mode 100644 index 000000000..4a0518ace --- /dev/null +++ b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx @@ -0,0 +1,118 @@ +/** + * Wrapper component that injects AI suggestions into the Tags sidebar panel. + */ + +/** + * WordPress dependencies + */ +import { useEffect, useState, createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SuggestionPanel from './SuggestionPanel'; + +/** + * Container ID for the tag suggestions. + */ +const CONTAINER_ID = 'ai-contextual-tagging-tags'; + +/** + * Finds a sidebar panel by its toggle button text. + * + * @param title The panel title text to search for. + * @return The panel body element, or null if not found. + */ +function findPanelByTitle( title: string ): HTMLElement | null { + const panelBodies = document.querySelectorAll( + '.components-panel__body' + ); + + for ( const panel of panelBodies ) { + const toggle = panel.querySelector( + '.components-panel__body-toggle' + ); + if ( toggle?.textContent?.trim() === title ) { + return panel as HTMLElement; + } + } + + return null; +} + +/** + * TagPanelWrapper component. + * + * Uses DOM observation to inject the suggestion panel into the Tags + * sidebar panel in the block editor. + * + * @return null - Renders via portal. + */ +export default function TagPanelWrapper(): null { + const [ container, setContainer ] = useState< HTMLElement | null >( + null + ); + + useEffect( () => { + const findAndAttach = (): boolean => { + // Don't create duplicate containers. + if ( document.getElementById( CONTAINER_ID ) ) { + return true; + } + + // Find the Tags panel by its toggle button text. + const tagsPanel = findPanelByTitle( 'Tags' ); + + if ( ! tagsPanel ) { + return false; + } + + // Create and inject our container at the end of the panel. + const el = document.createElement( 'div' ); + el.id = CONTAINER_ID; + tagsPanel.appendChild( el ); + setContainer( el ); + return true; + }; + + // Try immediately. + if ( findAndAttach() ) { + return; + } + + // Observe for the panel appearing. + const observer = new MutationObserver( () => { + if ( findAndAttach() ) { + observer.disconnect(); + } + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + return () => { + observer.disconnect(); + const el = document.getElementById( CONTAINER_ID ); + if ( el ) { + el.remove(); + } + }; + }, [] ); + + useEffect( () => { + if ( ! container ) { + return; + } + + const root = createRoot( container ); + root.render( ); + + return () => { + root.unmount(); + }; + }, [ container ] ); + + return null; +} diff --git a/src/experiments/contextual-tagging/components/useContextualTagging.ts b/src/experiments/contextual-tagging/components/useContextualTagging.ts new file mode 100644 index 000000000..f39fa3632 --- /dev/null +++ b/src/experiments/contextual-tagging/components/useContextualTagging.ts @@ -0,0 +1,250 @@ +/** + * Shared hook for contextual tagging logic. + */ + +/** + * WordPress dependencies + */ +import { dispatch, resolveSelect, select } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState, useCallback } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import type { + ContextualTaggingAbilityInput, + ContextualTaggingResponse, + TagSuggestion, + ContextualTaggingData, +} from '../types'; + +const MINIMUM_WORD_COUNT = 150; +const NOTICE_ID = 'ai_contextual_tagging_error'; + +const getSettings = (): ContextualTaggingData => + ( window as any ).aiContextualTaggingData ?? { + enabled: false, + strategy: 'existing_only', + maxSuggestions: 5, + }; + +/** + * Generates taxonomy suggestions for the given post. + * + * @param postId The post ID. + * @param content The post content. + * @param taxonomy The taxonomy to suggest terms for. + * @param strategy The suggestion strategy. + * @param maxSuggestions The maximum number of suggestions. + * @return A promise that resolves to the generated suggestions. + */ +async function generateSuggestions( + postId: number, + content: string, + taxonomy: string, + strategy: string, + maxSuggestions: number +): Promise< TagSuggestion[] > { + const params: ContextualTaggingAbilityInput = { + content, + post_id: postId, + taxonomy, + strategy, + max_suggestions: maxSuggestions, + }; + + const response = await runAbility< ContextualTaggingResponse >( + 'ai/contextual-tagging', + params + ); + + if ( response?.suggestions && Array.isArray( response.suggestions ) ) { + return response.suggestions; + } + + return []; +} + +/** + * Hook for contextual tagging functionality. + * + * @param taxonomy The taxonomy to generate suggestions for. + * @return Object with generation state, suggestions, and handlers. + */ +export function useContextualTagging( taxonomy: string ): { + isGenerating: boolean; + suggestions: TagSuggestion[]; + hasEnoughContent: boolean; + handleGenerate: () => Promise< void >; + handleAccept: ( suggestion: TagSuggestion ) => void; + handleDismiss: ( suggestion: TagSuggestion ) => void; + handleDismissAll: () => void; +} { + const postId = select( editorStore ).getCurrentPostId() as number; + const content = select( editorStore ).getEditedPostContent(); + const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); + const [ suggestions, setSuggestions ] = useState< TagSuggestion[] >( [] ); + + // Check if content has enough words. + const wordCount = content + ? content.replace( /<[^>]*>/g, '' ).split( /\s+/ ).filter( Boolean ) + .length + : 0; + const hasEnoughContent = wordCount >= MINIMUM_WORD_COUNT; + + const handleGenerate = useCallback( async () => { + const settings = getSettings(); + setIsGenerating( true ); + setSuggestions( [] ); + ( dispatch( noticesStore ) as any ).removeNotice( NOTICE_ID ); + + try { + const result = await generateSuggestions( + postId, + content, + taxonomy, + settings.strategy, + settings.maxSuggestions + ); + setSuggestions( result ); + } catch ( error: any ) { + ( dispatch( noticesStore ) as any ).createErrorNotice( + error?.message || error, + { + id: NOTICE_ID, + isDismissible: true, + } + ); + } finally { + setIsGenerating( false ); + } + }, [ postId, content, taxonomy ] ); + + const handleAccept = useCallback( + ( suggestion: TagSuggestion ) => { + // Remove from suggestions list. + setSuggestions( ( prev ) => + prev.filter( ( s ) => s.term !== suggestion.term ) + ); + + // Add the term to the post. + addTermToPost( taxonomy, suggestion ); + }, + [ taxonomy ] + ); + + const handleDismiss = useCallback( ( suggestion: TagSuggestion ) => { + setSuggestions( ( prev ) => + prev.filter( ( s ) => s.term !== suggestion.term ) + ); + }, [] ); + + const handleDismissAll = useCallback( () => { + setSuggestions( [] ); + }, [] ); + + return { + isGenerating, + suggestions, + hasEnoughContent, + handleGenerate, + handleAccept, + handleDismiss, + handleDismissAll, + }; +} + +/** + * Adds a term to the current post. + * + * @param taxonomy The taxonomy slug. + * @param suggestion The suggestion to add. + */ +async function addTermToPost( + taxonomy: string, + suggestion: TagSuggestion +): Promise< void > { + const { editPost } = dispatch( editorStore ); + + if ( taxonomy === 'post_tag' ) { + // For tags, we can use the tag name directly via the editor store. + // WordPress handles creating new tags automatically when saving. + const currentTags: string[] = + ( select( editorStore ) as any ).getEditedPostAttribute( 'tags' ) ?? + []; + + // Look up the term by name to get its ID, or create it. + const termId = await findOrCreateTerm( taxonomy, suggestion.term ); + + if ( termId && ! currentTags.includes( termId as any ) ) { + ( editPost as any )( { + tags: [ ...currentTags, termId ], + } ); + } + } else if ( taxonomy === 'category' ) { + const currentCategories: number[] = + ( select( editorStore ) as any ).getEditedPostAttribute( + 'categories' + ) ?? []; + + const termId = await findOrCreateTerm( taxonomy, suggestion.term ); + + if ( termId && ! currentCategories.includes( termId ) ) { + ( editPost as any )( { + categories: [ ...currentCategories, termId ], + } ); + } + } +} + +/** + * Finds an existing term by name or creates a new one. + * + * @param taxonomy The taxonomy slug. + * @param termName The term name. + * @return The term ID, or null if not found and could not be created. + */ +async function findOrCreateTerm( + taxonomy: string, + termName: string +): Promise< number | null > { + // Map taxonomy slug to REST base. + const restBase = taxonomy === 'post_tag' ? 'tags' : 'categories'; + + try { + // Search for existing term. + const searchResults: any[] = await ( + resolveSelect( coreStore ) as any + ).getEntityRecords( 'taxonomy', restBase, { + search: termName, + per_page: 100, + } ); + + // If we have a direct match, return its ID. + if ( Array.isArray( searchResults ) ) { + const match = searchResults.find( + ( t: any ) => + t.name.toLowerCase() === termName.toLowerCase() + ); + if ( match ) { + return match.id; + } + } + + // Create new term via REST. + const newTerm: any = await apiFetch( { + path: `/wp/v2/${ restBase }`, + method: 'POST', + data: { name: termName }, + } ); + + return newTerm?.id ?? null; + } catch { + return null; + } +} diff --git a/src/experiments/contextual-tagging/index.tsx b/src/experiments/contextual-tagging/index.tsx new file mode 100644 index 000000000..ad7c300f6 --- /dev/null +++ b/src/experiments/contextual-tagging/index.tsx @@ -0,0 +1,24 @@ +/** + * Contextual tagging experiment plugin registration. + */ + +/** + * WordPress dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import TagPanelWrapper from './components/TagPanelWrapper'; +import CategoryPanelWrapper from './components/CategoryPanelWrapper'; + +// Register plugin for tag suggestions in the Tags sidebar panel. +registerPlugin( 'ai-contextual-tagging-tags', { + render: TagPanelWrapper, +} ); + +// Register plugin for category suggestions in the Categories sidebar panel. +registerPlugin( 'ai-contextual-tagging-categories', { + render: CategoryPanelWrapper, +} ); diff --git a/src/experiments/contextual-tagging/types.ts b/src/experiments/contextual-tagging/types.ts new file mode 100644 index 000000000..d355963c0 --- /dev/null +++ b/src/experiments/contextual-tagging/types.ts @@ -0,0 +1,41 @@ +/** + * Type definitions for contextual tagging experiment. + */ + +/** + * Input parameters for the ai/contextual-tagging ability. + */ +export interface ContextualTaggingAbilityInput { + content: string; + post_id: number; + taxonomy: string; + strategy: string; + max_suggestions: number; + [ key: string ]: string | number | undefined; +} + +/** + * A single taxonomy term suggestion from the AI. + */ +export interface TagSuggestion { + term: string; + confidence: number; + is_new: boolean; + parent?: string; +} + +/** + * Response from the ai/contextual-tagging ability. + */ +export interface ContextualTaggingResponse { + suggestions: TagSuggestion[]; +} + +/** + * Localized data from the PHP side. + */ +export interface ContextualTaggingData { + enabled: boolean; + strategy: string; + maxSuggestions: number; +} diff --git a/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php b/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php new file mode 100644 index 000000000..0606697d1 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php @@ -0,0 +1,403 @@ + 'contextual-tagging', + 'label' => 'Contextual Tagging', + 'description' => 'AI-powered suggestions for post tags and categories.', + ); + } + + /** + * Registers the experiment. + * + * @since 0.6.0 + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Contextual_Tagging Ability test case. + * + * @since 0.6.0 + */ +class Contextual_TaggingTest extends WP_UnitTestCase { + + /** + * Contextual_Tagging ability instance. + * + * @var \WordPress\AI\Abilities\Contextual_Tagging\Contextual_Tagging + */ + private $ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Contextual_Tagging_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since 0.6.0 + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Contextual_Tagging_Experiment(); + $this->ability = new Contextual_Tagging( + 'ai/contextual-tagging', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since 0.6.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since 0.6.0 + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since 0.6.0 + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); + $this->assertArrayHasKey( 'post_id', $schema['properties'], 'Schema should have post_id property' ); + $this->assertArrayHasKey( 'taxonomy', $schema['properties'], 'Schema should have taxonomy property' ); + $this->assertArrayHasKey( 'strategy', $schema['properties'], 'Schema should have strategy property' ); + $this->assertArrayHasKey( 'max_suggestions', $schema['properties'], 'Schema should have max_suggestions property' ); + + // Verify taxonomy property. + $this->assertEquals( 'string', $schema['properties']['taxonomy']['type'], 'Taxonomy should be string type' ); + $this->assertEquals( 'post_tag', $schema['properties']['taxonomy']['default'], 'Taxonomy default should be post_tag' ); + + // Verify strategy property. + $this->assertEquals( 'string', $schema['properties']['strategy']['type'], 'Strategy should be string type' ); + $this->assertEquals( 'existing_only', $schema['properties']['strategy']['default'], 'Strategy default should be existing_only' ); + + // Verify max_suggestions property. + $this->assertEquals( 'integer', $schema['properties']['max_suggestions']['type'], 'max_suggestions should be integer type' ); + $this->assertEquals( 1, $schema['properties']['max_suggestions']['minimum'], 'max_suggestions minimum should be 1' ); + $this->assertEquals( 10, $schema['properties']['max_suggestions']['maximum'], 'max_suggestions maximum should be 10' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since 0.6.0 + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'suggestions', $schema['properties'], 'Schema should have suggestions property' ); + $this->assertEquals( 'array', $schema['properties']['suggestions']['type'], 'Suggestions should be array type' ); + $this->assertArrayHasKey( 'items', $schema['properties']['suggestions'], 'Suggestions should have items' ); + + // Verify suggestion item properties. + $item_props = $schema['properties']['suggestions']['items']['properties']; + $this->assertArrayHasKey( 'term', $item_props, 'Item should have term property' ); + $this->assertArrayHasKey( 'confidence', $item_props, 'Item should have confidence property' ); + $this->assertArrayHasKey( 'is_new', $item_props, 'Item should have is_new property' ); + $this->assertArrayHasKey( 'parent', $item_props, 'Item should have parent property' ); + } + + /** + * Test that get_system_instruction() returns the system instruction. + * + * @since 0.6.0 + */ + public function test_get_system_instruction_returns_system_instruction() { + $system_instruction = $this->ability->get_system_instruction( + null, + array( + 'strategy' => 'Only suggest existing terms.', + 'max_suggestions' => 5, + 'taxonomy' => 'tags', + 'existing_terms' => 'Existing terms: wordpress, plugins', + ) + ); + + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); + $this->assertStringContainsString( 'tags', $system_instruction, 'System instruction should contain the taxonomy name' ); + } + + /** + * Test that execute_callback() returns error when content is missing. + * + * @since 0.6.0 + */ + public function test_execute_callback_without_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array(); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() returns error when post_id points to non-existent post. + * + * @since 0.6.0 + */ + public function test_execute_callback_with_invalid_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'post_id' => 99999, // Non-existent post ID. + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that execute_callback() returns error for invalid taxonomy. + * + * @since 0.6.0 + */ + public function test_execute_callback_with_invalid_taxonomy() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'Test content for taxonomy suggestions.', + 'taxonomy' => 'nonexistent_taxonomy', + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'invalid_taxonomy', $result->get_error_code(), 'Error code should be invalid_taxonomy' ); + } + + /** + * Test that parse_suggestions() handles valid JSON correctly. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_with_valid_json() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = '[{"term": "development", "confidence": 0.9, "is_new": false}, {"term": "plugins", "confidence": 0.8, "is_new": true}]'; + + $result = $method->invoke( $this->ability, $response, array( 'development' ), 5 ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertCount( 2, $result, 'Should have 2 suggestions' ); + $this->assertEquals( 'development', $result[0]['term'], 'First suggestion should be development' ); + $this->assertFalse( $result[0]['is_new'], 'Existing term should not be marked as new' ); + $this->assertTrue( $result[1]['is_new'], 'Non-existing term should be marked as new' ); + } + + /** + * Test that parse_suggestions() handles markdown-wrapped JSON. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_with_markdown_json() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = "```json\n[{\"term\": \"ai\", \"confidence\": 0.95, \"is_new\": false}]\n```"; + + $result = $method->invoke( $this->ability, $response, array( 'ai' ), 5 ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertCount( 1, $result, 'Should have 1 suggestion' ); + $this->assertEquals( 'ai', $result[0]['term'], 'Suggestion should be ai' ); + } + + /** + * Test that parse_suggestions() returns error for invalid JSON. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_with_invalid_json() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = 'This is not valid JSON'; + + $result = $method->invoke( $this->ability, $response, array(), 5 ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'invalid_response', $result->get_error_code(), 'Error code should be invalid_response' ); + } + + /** + * Test that parse_suggestions() limits results to max_suggestions. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_limits_results() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = '[ + {"term": "a", "confidence": 0.9, "is_new": false}, + {"term": "b", "confidence": 0.8, "is_new": false}, + {"term": "c", "confidence": 0.7, "is_new": false}, + {"term": "d", "confidence": 0.6, "is_new": false}, + {"term": "e", "confidence": 0.5, "is_new": false} + ]'; + + $result = $method->invoke( $this->ability, $response, array(), 3 ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertCount( 3, $result, 'Should be limited to 3 suggestions' ); + $this->assertEquals( 'a', $result[0]['term'], 'First suggestion should be highest confidence' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since 0.6.0 + */ + public function test_permission_callback_with_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + * + * @since 0.6.0 + */ + public function test_permission_callback_without_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns error for logged out user. + * + * @since 0.6.0 + */ + public function test_permission_callback_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that meta() returns the expected meta structure. + * + * @since 0.6.0 + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } +} diff --git a/tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php b/tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php new file mode 100644 index 000000000..4c1540886 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php @@ -0,0 +1,121 @@ + 'test-api-key' ) ); + + // Mock has_valid_ai_credentials to return true for tests. + add_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' ); + + // Enable experiments globally and individually. + update_option( 'ai_experiments_enabled', true ); + update_option( 'ai_experiment_contextual-tagging_enabled', true ); + + $registry = new Experiment_Registry(); + $loader = new Experiment_Loader( $registry ); + $loader->register_default_experiments(); + + $experiment = $registry->get_experiment( 'contextual-tagging' ); + $this->assertInstanceOf( Contextual_Tagging::class, $experiment, 'Contextual tagging experiment should be registered in the registry.' ); + } + + /** + * Tear down test case. + * + * @since 0.6.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'ai_experiments_enabled' ); + delete_option( 'ai_experiment_contextual-tagging_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Test that the experiment is registered correctly. + * + * @since 0.6.0 + */ + public function test_experiment_registration() { + $experiment = new Contextual_Tagging(); + + $this->assertEquals( 'contextual-tagging', $experiment->get_id() ); + $this->assertEquals( 'Contextual Tagging', $experiment->get_label() ); + $this->assertEquals( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that experiment settings are registered. + * + * @since 0.6.0 + */ + public function test_experiment_settings_registration() { + $experiment = new Contextual_Tagging(); + $experiment->register_settings(); + + // Verify the settings are registered by checking they can be retrieved. + $strategy = get_option( 'ai_experiment_contextual-tagging_field_strategy', 'existing_only' ); + $this->assertEquals( 'existing_only', $strategy ); + + $max_suggestions = get_option( 'ai_experiment_contextual-tagging_field_max_suggestions', 5 ); + $this->assertEquals( 5, $max_suggestions ); + } + + /** + * Test that strategy sanitization works correctly. + * + * @since 0.6.0 + */ + public function test_sanitize_strategy() { + $experiment = new Contextual_Tagging(); + + $this->assertEquals( 'existing_only', $experiment->sanitize_strategy( 'existing_only' ) ); + $this->assertEquals( 'allow_new', $experiment->sanitize_strategy( 'allow_new' ) ); + $this->assertEquals( 'existing_only', $experiment->sanitize_strategy( 'invalid_value' ) ); + $this->assertEquals( 'existing_only', $experiment->sanitize_strategy( '' ) ); + } + + /** + * Test that max suggestions sanitization works correctly. + * + * @since 0.6.0 + */ + public function test_sanitize_max_suggestions() { + $experiment = new Contextual_Tagging(); + + $this->assertEquals( 5, $experiment->sanitize_max_suggestions( 5 ) ); + $this->assertEquals( 1, $experiment->sanitize_max_suggestions( 0 ) ); + $this->assertEquals( 1, $experiment->sanitize_max_suggestions( -1 ) ); + $this->assertEquals( 10, $experiment->sanitize_max_suggestions( 15 ) ); + $this->assertEquals( 7, $experiment->sanitize_max_suggestions( '7' ) ); + } +} diff --git a/webpack.config.js b/webpack.config.js index bb9292578..d2d6f6511 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,6 +19,11 @@ module.exports = { 'src/admin/settings', 'index.scss' ), + 'experiments/contextual-tagging': path.resolve( + process.cwd(), + 'src/experiments/contextual-tagging', + 'index.tsx' + ), 'experiments/abilities-explorer': path.resolve( process.cwd(), 'src/experiments/abilities-explorer', From 5c4bbbe00acffdce759c9f39072caec64734a833 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 11:47:58 -0400 Subject: [PATCH 02/72] Add styles to the Contextual Tagging editor UI elements --- .../Contextual_Tagging/Contextual_Tagging.php | 72 ++++++++++++--- .../Contextual_Tagging/Contextual_Tagging.php | 1 + src/experiments/contextual-tagging/index.scss | 90 +++++++++++++++++++ src/experiments/contextual-tagging/index.tsx | 5 ++ 4 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 src/experiments/contextual-tagging/index.scss diff --git a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php index 822eafbdf..5e1aa38c8 100644 --- a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php +++ b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php @@ -317,19 +317,17 @@ static function ( $key, $value ) { // Get the taxonomy label for the prompt. $taxonomy_label = $this->get_taxonomy_label( $taxonomy ); + // Build the system instruction directly to avoid esc_html() escaping JSON syntax. + $system_instruction = $this->build_system_instruction( + $taxonomy_label, + $max_suggestions, + $strategy_instruction, + $existing_terms_instruction + ); + // Generate the suggestions using the AI client. $result = wp_ai_client_prompt( '"""' . $context . '"""' ) - ->using_system_instruction( - $this->get_system_instruction( - null, - array( - 'strategy' => $strategy_instruction, - 'max_suggestions' => $max_suggestions, - 'taxonomy' => $taxonomy_label, - 'existing_terms' => $existing_terms_instruction, - ) - ) - ) + ->using_system_instruction( $system_instruction ) ->using_temperature( 0.5 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) ->generate_text(); @@ -424,6 +422,58 @@ protected function get_taxonomy_label( string $taxonomy ): string { return $taxonomy; } + /** + * Builds the system instruction for the AI prompt. + * + * Built directly rather than loaded from a file to avoid esc_html() + * escaping JSON syntax characters in the instruction. + * + * @since 0.6.0 + * + * @param string $taxonomy The taxonomy label. + * @param int $max_suggestions The maximum number of suggestions. + * @param string $strategy The strategy instruction text. + * @param string $existing_terms The existing terms instruction text. + * @return string The system instruction. + */ + protected function build_system_instruction( + string $taxonomy, + int $max_suggestions, + string $strategy, + string $existing_terms + ): string { + return implode( + "\n", + array( + "You are a content taxonomy assistant for a WordPress website. Your task is to analyze article content and suggest relevant {$taxonomy} terms.", + '', + "Goal: Analyze the provided content (title, body, and any existing context) and suggest up to {$max_suggestions} relevant terms for the {$taxonomy} taxonomy.", + '', + 'Output format:', + 'Return ONLY a valid JSON array. No prose, no markdown, no code fences.', + 'Each element is an object with these keys:', + ' "term" - a string with the suggested term name (1-3 words, lowercase)', + ' "confidence" - a number between 0 and 1', + ' "is_new" - a boolean indicating if this term does not already exist on the site', + ' "parent" - (optional, categories only) string name of the parent category', + '', + 'Example output for an article about machine learning in healthcare:', + '[{"term": "machine learning", "confidence": 0.95, "is_new": true}, {"term": "healthcare", "confidence": 0.9, "is_new": false}]', + '', + 'Rules:', + '- The "term" field must contain ONLY the human-readable tag or category name.', + '- Confidence should reflect relevance: 1.0 = perfect match, 0.5 = somewhat relevant.', + '- Do not suggest duplicate or near-duplicate terms.', + '- Prioritize specificity and relevance over breadth.', + '- Sort suggestions by confidence, highest first.', + $strategy, + $existing_terms, + '', + 'The content you will be provided is delimited by triple quotes.', + ) + ); + } + /** * Parses the AI response into structured suggestions. * diff --git a/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php b/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php index c44db9374..01fec18cc 100644 --- a/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php +++ b/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php @@ -121,6 +121,7 @@ public function enqueue_assets( string $hook_suffix ): void { } Asset_Loader::enqueue_script( 'contextual_tagging', 'experiments/contextual-tagging' ); + Asset_Loader::enqueue_style( 'contextual_tagging', 'experiments/contextual-tagging' ); Asset_Loader::localize_script( 'contextual_tagging', 'ContextualTaggingData', diff --git a/src/experiments/contextual-tagging/index.scss b/src/experiments/contextual-tagging/index.scss new file mode 100644 index 000000000..003424f0f --- /dev/null +++ b/src/experiments/contextual-tagging/index.scss @@ -0,0 +1,90 @@ +.ai-contextual-tagging { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + + &__generate-button { + width: 100%; + justify-content: center; + } + + &__hint { + color: #757575; + font-size: 12px; + font-style: italic; + margin-top: 4px; + } + + &__loading { + display: flex; + justify-content: center; + padding: 8px 0; + } + + &__pills { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + + &__pill { + display: inline-flex; + align-items: center; + border: 1px solid #c3c4c7; + border-radius: 16px; + background: #f0f0f1; + overflow: hidden; + + &--new { + border-style: dashed; + background: #f0f6fc; + border-color: #72aee6; + } + } + + &__pill-accept { + padding: 2px 4px 2px 10px !important; + min-height: 28px !important; + font-size: 12px !important; + border: none !important; + background: transparent !important; + cursor: pointer; + + &:hover { + color: var(--wp-admin-theme-color, #3858e9); + } + } + + &__pill-badge { + display: inline-block; + margin-left: 4px; + padding: 1px 5px; + border-radius: 8px; + background: #72aee6; + color: #fff; + font-size: 10px; + line-height: 1.4; + vertical-align: middle; + } + + &__pill-dismiss { + min-height: 28px !important; + min-width: 24px !important; + padding: 0 4px !important; + border: none !important; + background: transparent !important; + border-left: 1px solid #c3c4c7 !important; + border-radius: 0 !important; + + &:hover { + color: #d63638; + } + } + + &__actions { + display: flex; + gap: 12px; + font-size: 12px; + } +} diff --git a/src/experiments/contextual-tagging/index.tsx b/src/experiments/contextual-tagging/index.tsx index ad7c300f6..e7da500ea 100644 --- a/src/experiments/contextual-tagging/index.tsx +++ b/src/experiments/contextual-tagging/index.tsx @@ -2,6 +2,11 @@ * Contextual tagging experiment plugin registration. */ +/** + * Styles + */ +import './index.scss'; + /** * WordPress dependencies */ From 4393d4591cac65a0d088ab1334d854ef3eca2ed7 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 12:11:29 -0400 Subject: [PATCH 03/72] Update the button injection into the term panels. Ensure the suggestion buttons are attached to the panel visibility. --- .../components/CategoryPanelWrapper.tsx | 93 +++----------- .../components/TagPanelWrapper.tsx | 91 +++----------- .../components/usePanelInjection.ts | 116 ++++++++++++++++++ 3 files changed, 150 insertions(+), 150 deletions(-) create mode 100644 src/experiments/contextual-tagging/components/usePanelInjection.ts diff --git a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx b/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx index 2fb17460e..a97ab2340 100644 --- a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx +++ b/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx @@ -5,41 +5,20 @@ /** * WordPress dependencies */ -import { useEffect, useState, createRoot } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; /** * Internal dependencies */ import SuggestionPanel from './SuggestionPanel'; +import { usePanelInjection } from './usePanelInjection'; /** * Container ID for the category suggestions. */ const CONTAINER_ID = 'ai-contextual-tagging-categories'; -/** - * Finds a sidebar panel by its toggle button text. - * - * @param title The panel title text to search for. - * @return The panel body element, or null if not found. - */ -function findPanelByTitle( title: string ): HTMLElement | null { - const panelBodies = document.querySelectorAll( - '.components-panel__body' - ); - - for ( const panel of panelBodies ) { - const toggle = panel.querySelector( - '.components-panel__body-toggle' - ); - if ( toggle?.textContent?.trim() === title ) { - return panel as HTMLElement; - } - } - - return null; -} - /** * CategoryPanelWrapper component. * @@ -49,68 +28,32 @@ function findPanelByTitle( title: string ): HTMLElement | null { * @return null - Renders via portal. */ export default function CategoryPanelWrapper(): null { - const [ container, setContainer ] = useState< HTMLElement | null >( + const container = usePanelInjection( 'Categories', CONTAINER_ID ); + const rootRef = useRef< ReturnType< typeof createRoot > | null >( null ); useEffect( () => { - const findAndAttach = (): boolean => { - // Don't create duplicate containers. - if ( document.getElementById( CONTAINER_ID ) ) { - return true; - } - - // Find the Categories panel by its toggle button text. - const categoriesPanel = findPanelByTitle( 'Categories' ); - - if ( ! categoriesPanel ) { - return false; + if ( ! container ) { + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; } - - // Create and inject our container at the end of the panel. - const el = document.createElement( 'div' ); - el.id = CONTAINER_ID; - categoriesPanel.appendChild( el ); - setContainer( el ); - return true; - }; - - // Try immediately. - if ( findAndAttach() ) { return; } - // Observe for the panel appearing. - const observer = new MutationObserver( () => { - if ( findAndAttach() ) { - observer.disconnect(); - } - } ); - - observer.observe( document.body, { - childList: true, - subtree: true, - } ); - - return () => { - observer.disconnect(); - const el = document.getElementById( CONTAINER_ID ); - if ( el ) { - el.remove(); - } - }; - }, [] ); - - useEffect( () => { - if ( ! container ) { - return; + if ( ! rootRef.current ) { + rootRef.current = createRoot( container ); } - - const root = createRoot( container ); - root.render( ); + rootRef.current.render( + + ); return () => { - root.unmount(); + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; + } }; }, [ container ] ); diff --git a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx index 4a0518ace..53af1d907 100644 --- a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx +++ b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx @@ -5,41 +5,20 @@ /** * WordPress dependencies */ -import { useEffect, useState, createRoot } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; /** * Internal dependencies */ import SuggestionPanel from './SuggestionPanel'; +import { usePanelInjection } from './usePanelInjection'; /** * Container ID for the tag suggestions. */ const CONTAINER_ID = 'ai-contextual-tagging-tags'; -/** - * Finds a sidebar panel by its toggle button text. - * - * @param title The panel title text to search for. - * @return The panel body element, or null if not found. - */ -function findPanelByTitle( title: string ): HTMLElement | null { - const panelBodies = document.querySelectorAll( - '.components-panel__body' - ); - - for ( const panel of panelBodies ) { - const toggle = panel.querySelector( - '.components-panel__body-toggle' - ); - if ( toggle?.textContent?.trim() === title ) { - return panel as HTMLElement; - } - } - - return null; -} - /** * TagPanelWrapper component. * @@ -49,68 +28,30 @@ function findPanelByTitle( title: string ): HTMLElement | null { * @return null - Renders via portal. */ export default function TagPanelWrapper(): null { - const [ container, setContainer ] = useState< HTMLElement | null >( + const container = usePanelInjection( 'Tags', CONTAINER_ID ); + const rootRef = useRef< ReturnType< typeof createRoot > | null >( null ); useEffect( () => { - const findAndAttach = (): boolean => { - // Don't create duplicate containers. - if ( document.getElementById( CONTAINER_ID ) ) { - return true; - } - - // Find the Tags panel by its toggle button text. - const tagsPanel = findPanelByTitle( 'Tags' ); - - if ( ! tagsPanel ) { - return false; + if ( ! container ) { + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; } - - // Create and inject our container at the end of the panel. - const el = document.createElement( 'div' ); - el.id = CONTAINER_ID; - tagsPanel.appendChild( el ); - setContainer( el ); - return true; - }; - - // Try immediately. - if ( findAndAttach() ) { return; } - // Observe for the panel appearing. - const observer = new MutationObserver( () => { - if ( findAndAttach() ) { - observer.disconnect(); - } - } ); - - observer.observe( document.body, { - childList: true, - subtree: true, - } ); - - return () => { - observer.disconnect(); - const el = document.getElementById( CONTAINER_ID ); - if ( el ) { - el.remove(); - } - }; - }, [] ); - - useEffect( () => { - if ( ! container ) { - return; + if ( ! rootRef.current ) { + rootRef.current = createRoot( container ); } - - const root = createRoot( container ); - root.render( ); + rootRef.current.render( ); return () => { - root.unmount(); + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; + } }; }, [ container ] ); diff --git a/src/experiments/contextual-tagging/components/usePanelInjection.ts b/src/experiments/contextual-tagging/components/usePanelInjection.ts new file mode 100644 index 000000000..1b9c0f503 --- /dev/null +++ b/src/experiments/contextual-tagging/components/usePanelInjection.ts @@ -0,0 +1,116 @@ +/** + * Shared hook for injecting a container into an editor sidebar panel. + * + * Handles panel toggle cycles by continuously observing the DOM + * and re-injecting the container when the panel is reopened. + */ + +/** + * WordPress dependencies + */ +import { useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Finds a sidebar panel by its toggle button text. + * + * @param title The panel title text to search for. + * @return The panel body element, or null if not found. + */ +function findPanelByTitle( title: string ): HTMLElement | null { + const panelBodies = document.querySelectorAll( + '.components-panel__body' + ); + + for ( const panel of panelBodies ) { + const toggle = panel.querySelector( + '.components-panel__body-toggle' + ); + if ( toggle?.textContent?.trim() === title ) { + return panel as HTMLElement; + } + } + + return null; +} + +/** + * Hook that injects and maintains a container element at the end of a + * named editor sidebar panel. Handles panel toggle (close/reopen) by + * continuously observing the DOM and re-injecting as needed. + * + * @param panelTitle The panel toggle button text (e.g., "Tags"). + * @param containerId A unique ID for the injected container element. + * @return The container element, or null if not yet injected. + */ +export function usePanelInjection( + panelTitle: string, + containerId: string +): HTMLElement | null { + const [ container, setContainer ] = useState< HTMLElement | null >( + null + ); + const containerRef = useRef< HTMLElement | null >( null ); + + useEffect( () => { + const findAndAttach = (): void => { + const panel = findPanelByTitle( panelTitle ); + const isOpen = + panel && panel.classList.contains( 'is-opened' ); + + // If the panel is closed or gone, remove our container. + if ( ! isOpen ) { + const existing = + document.getElementById( containerId ); + if ( existing ) { + existing.remove(); + } + if ( containerRef.current ) { + containerRef.current = null; + setContainer( null ); + } + return; + } + + // Panel is open — check if our container already exists. + const existing = document.getElementById( containerId ); + if ( existing ) { + // Ensure it's the last child (not displaced by re-renders). + if ( panel.lastElementChild !== existing ) { + panel.appendChild( existing ); + } + return; + } + + // Create and inject our container at the end of the panel. + const el = document.createElement( 'div' ); + el.id = containerId; + panel.appendChild( el ); + containerRef.current = el; + setContainer( el ); + }; + + // Try immediately. + findAndAttach(); + + // Continuously observe for panel toggles and re-renders. + const observer = new MutationObserver( () => { + findAndAttach(); + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + return () => { + observer.disconnect(); + const el = document.getElementById( containerId ); + if ( el ) { + el.remove(); + } + containerRef.current = null; + }; + }, [ panelTitle, containerId ] ); + + return container; +} From 0e22a9eba8fee57f28ced6c1bf54cd56e3f76f2f Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 12:22:33 -0400 Subject: [PATCH 04/72] Minor styling update to editor UI --- .../contextual-tagging/components/SuggestionPanel.tsx | 10 +++------- src/experiments/contextual-tagging/index.scss | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx index 963e5cd97..16b2be2ae 100644 --- a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx +++ b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx @@ -58,14 +58,12 @@ export default function SuggestionPanel( { disabled={ isGenerating || ! hasEnoughContent } isBusy={ isGenerating } className="ai-contextual-tagging__generate-button" + __next40pxDefaultSize > { isGenerating ? __( 'Generating…', 'ai' ) : /* translators: %s: Taxonomy label (e.g., "Tags" or "Categories"). */ - sprintf( - __( 'Suggest %s', 'ai' ), - taxonomyLabel - ) } + sprintf( __( 'Suggest %s', 'ai' ), taxonomyLabel ) } ) } @@ -98,9 +96,7 @@ export default function SuggestionPanel( { > ) } { ! hasEnoughContent && ! hasSuggestions && ( -

+

{ __( 'Add more content to enable AI suggestions (approximately 150 words).', 'ai' @@ -85,63 +93,79 @@ export default function SuggestionPanel( { { hasSuggestions && (

- { suggestions.map( ( suggestion: TagSuggestion ) => ( - - -
-
- - + +
+ + + + + + + +
) } diff --git a/src/experiments/contextual-tagging/components/usePanelInjection.ts b/src/experiments/contextual-tagging/components/usePanelInjection.ts index 1b9c0f503..d57f8049b 100644 --- a/src/experiments/contextual-tagging/components/usePanelInjection.ts +++ b/src/experiments/contextual-tagging/components/usePanelInjection.ts @@ -52,7 +52,13 @@ export function usePanelInjection( const containerRef = useRef< HTMLElement | null >( null ); useEffect( () => { + let mutating = false; + const findAndAttach = (): void => { + if ( mutating ) { + return; + } + const panel = findPanelByTitle( panelTitle ); const isOpen = panel && panel.classList.contains( 'is-opened' ); @@ -62,7 +68,9 @@ export function usePanelInjection( const existing = document.getElementById( containerId ); if ( existing ) { + mutating = true; existing.remove(); + mutating = false; } if ( containerRef.current ) { containerRef.current = null; @@ -75,8 +83,13 @@ export function usePanelInjection( const existing = document.getElementById( containerId ); if ( existing ) { // Ensure it's the last child (not displaced by re-renders). - if ( panel.lastElementChild !== existing ) { + if ( + panel && + panel.lastElementChild !== existing + ) { + mutating = true; panel.appendChild( existing ); + mutating = false; } return; } @@ -84,7 +97,9 @@ export function usePanelInjection( // Create and inject our container at the end of the panel. const el = document.createElement( 'div' ); el.id = containerId; - panel.appendChild( el ); + mutating = true; + panel!.appendChild( el ); + mutating = false; containerRef.current = el; setContainer( el ); }; diff --git a/src/experiments/contextual-tagging/index.scss b/src/experiments/contextual-tagging/index.scss index 87633a45e..13a685ddc 100644 --- a/src/experiments/contextual-tagging/index.scss +++ b/src/experiments/contextual-tagging/index.scss @@ -1,16 +1,13 @@ .ai-contextual-tagging { margin-top: 12px; padding-top: 12px; - border-top: 1px solid #e0e0e0; + border-top: 1px solid var(--wp-admin-border-color, #c3c4c7); &__generate-button { width: 100%; } &__hint { - color: #757575; - font-size: 12px; - font-style: italic; margin-top: 4px; } @@ -23,23 +20,17 @@ &__pills { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; margin-bottom: 8px; } &__pill { display: inline-flex; align-items: center; - border: 1px solid #c3c4c7; - border-radius: 16px; - background: #f0f0f1; + border: 1px solid var(--wp-admin-theme-color, #007cba); + border-radius: 2px; + background: rgba(var(--wp-admin-theme-color--rgb, 0, 124, 186), 0.04); overflow: hidden; - - &--new { - border-style: dashed; - background: #f0f6fc; - border-color: #72aee6; - } } &__pill-accept { @@ -51,7 +42,7 @@ cursor: pointer; &:hover { - color: var(--wp-admin-theme-color, #3858e9); + color: var(--wp-admin-theme-color, #007cba); } } @@ -59,8 +50,8 @@ display: inline-block; margin-left: 4px; padding: 1px 5px; - border-radius: 8px; - background: #72aee6; + border-radius: 2px; + background: var(--wp-admin-theme-color, #007cba); color: #fff; font-size: 10px; line-height: 1.4; @@ -73,17 +64,15 @@ padding: 0 4px !important; border: none !important; background: transparent !important; - border-left: 1px solid #c3c4c7 !important; + border-left: 1px solid var(--wp-admin-theme-color, #007cba) !important; border-radius: 0 !important; &:hover { - color: #d63638; + color: #cc1818; } } &__actions { - display: flex; - gap: 12px; font-size: 12px; } } From fee542293375ccaa7af074e96036c989676ce737 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 13:07:25 -0400 Subject: [PATCH 06/72] Use the editor.PostTaxonomyType filter to inject the contextual tagging components --- .../components/CategoryPanelWrapper.tsx | 61 -------- .../components/TagPanelWrapper.tsx | 59 -------- .../components/usePanelInjection.ts | 131 ------------------ src/experiments/contextual-tagging/index.tsx | 48 +++++-- 4 files changed, 35 insertions(+), 264 deletions(-) delete mode 100644 src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx delete mode 100644 src/experiments/contextual-tagging/components/TagPanelWrapper.tsx delete mode 100644 src/experiments/contextual-tagging/components/usePanelInjection.ts diff --git a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx b/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx deleted file mode 100644 index a97ab2340..000000000 --- a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Wrapper component that injects AI suggestions into the Categories sidebar panel. - */ - -/** - * WordPress dependencies - */ -import { useEffect, useRef } from '@wordpress/element'; -import { createRoot } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SuggestionPanel from './SuggestionPanel'; -import { usePanelInjection } from './usePanelInjection'; - -/** - * Container ID for the category suggestions. - */ -const CONTAINER_ID = 'ai-contextual-tagging-categories'; - -/** - * CategoryPanelWrapper component. - * - * Uses DOM observation to inject the suggestion panel into the Categories - * sidebar panel in the block editor. - * - * @return null - Renders via portal. - */ -export default function CategoryPanelWrapper(): null { - const container = usePanelInjection( 'Categories', CONTAINER_ID ); - const rootRef = useRef< ReturnType< typeof createRoot > | null >( - null - ); - - useEffect( () => { - if ( ! container ) { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - return; - } - - if ( ! rootRef.current ) { - rootRef.current = createRoot( container ); - } - rootRef.current.render( - - ); - - return () => { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - }; - }, [ container ] ); - - return null; -} diff --git a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx deleted file mode 100644 index 53af1d907..000000000 --- a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Wrapper component that injects AI suggestions into the Tags sidebar panel. - */ - -/** - * WordPress dependencies - */ -import { useEffect, useRef } from '@wordpress/element'; -import { createRoot } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SuggestionPanel from './SuggestionPanel'; -import { usePanelInjection } from './usePanelInjection'; - -/** - * Container ID for the tag suggestions. - */ -const CONTAINER_ID = 'ai-contextual-tagging-tags'; - -/** - * TagPanelWrapper component. - * - * Uses DOM observation to inject the suggestion panel into the Tags - * sidebar panel in the block editor. - * - * @return null - Renders via portal. - */ -export default function TagPanelWrapper(): null { - const container = usePanelInjection( 'Tags', CONTAINER_ID ); - const rootRef = useRef< ReturnType< typeof createRoot > | null >( - null - ); - - useEffect( () => { - if ( ! container ) { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - return; - } - - if ( ! rootRef.current ) { - rootRef.current = createRoot( container ); - } - rootRef.current.render( ); - - return () => { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - }; - }, [ container ] ); - - return null; -} diff --git a/src/experiments/contextual-tagging/components/usePanelInjection.ts b/src/experiments/contextual-tagging/components/usePanelInjection.ts deleted file mode 100644 index d57f8049b..000000000 --- a/src/experiments/contextual-tagging/components/usePanelInjection.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Shared hook for injecting a container into an editor sidebar panel. - * - * Handles panel toggle cycles by continuously observing the DOM - * and re-injecting the container when the panel is reopened. - */ - -/** - * WordPress dependencies - */ -import { useEffect, useRef, useState } from '@wordpress/element'; - -/** - * Finds a sidebar panel by its toggle button text. - * - * @param title The panel title text to search for. - * @return The panel body element, or null if not found. - */ -function findPanelByTitle( title: string ): HTMLElement | null { - const panelBodies = document.querySelectorAll( - '.components-panel__body' - ); - - for ( const panel of panelBodies ) { - const toggle = panel.querySelector( - '.components-panel__body-toggle' - ); - if ( toggle?.textContent?.trim() === title ) { - return panel as HTMLElement; - } - } - - return null; -} - -/** - * Hook that injects and maintains a container element at the end of a - * named editor sidebar panel. Handles panel toggle (close/reopen) by - * continuously observing the DOM and re-injecting as needed. - * - * @param panelTitle The panel toggle button text (e.g., "Tags"). - * @param containerId A unique ID for the injected container element. - * @return The container element, or null if not yet injected. - */ -export function usePanelInjection( - panelTitle: string, - containerId: string -): HTMLElement | null { - const [ container, setContainer ] = useState< HTMLElement | null >( - null - ); - const containerRef = useRef< HTMLElement | null >( null ); - - useEffect( () => { - let mutating = false; - - const findAndAttach = (): void => { - if ( mutating ) { - return; - } - - const panel = findPanelByTitle( panelTitle ); - const isOpen = - panel && panel.classList.contains( 'is-opened' ); - - // If the panel is closed or gone, remove our container. - if ( ! isOpen ) { - const existing = - document.getElementById( containerId ); - if ( existing ) { - mutating = true; - existing.remove(); - mutating = false; - } - if ( containerRef.current ) { - containerRef.current = null; - setContainer( null ); - } - return; - } - - // Panel is open — check if our container already exists. - const existing = document.getElementById( containerId ); - if ( existing ) { - // Ensure it's the last child (not displaced by re-renders). - if ( - panel && - panel.lastElementChild !== existing - ) { - mutating = true; - panel.appendChild( existing ); - mutating = false; - } - return; - } - - // Create and inject our container at the end of the panel. - const el = document.createElement( 'div' ); - el.id = containerId; - mutating = true; - panel!.appendChild( el ); - mutating = false; - containerRef.current = el; - setContainer( el ); - }; - - // Try immediately. - findAndAttach(); - - // Continuously observe for panel toggles and re-renders. - const observer = new MutationObserver( () => { - findAndAttach(); - } ); - - observer.observe( document.body, { - childList: true, - subtree: true, - } ); - - return () => { - observer.disconnect(); - const el = document.getElementById( containerId ); - if ( el ) { - el.remove(); - } - containerRef.current = null; - }; - }, [ panelTitle, containerId ] ); - - return container; -} diff --git a/src/experiments/contextual-tagging/index.tsx b/src/experiments/contextual-tagging/index.tsx index e7da500ea..1156251a5 100644 --- a/src/experiments/contextual-tagging/index.tsx +++ b/src/experiments/contextual-tagging/index.tsx @@ -10,20 +10,42 @@ import './index.scss'; /** * WordPress dependencies */ -import { registerPlugin } from '@wordpress/plugins'; +import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies */ -import TagPanelWrapper from './components/TagPanelWrapper'; -import CategoryPanelWrapper from './components/CategoryPanelWrapper'; - -// Register plugin for tag suggestions in the Tags sidebar panel. -registerPlugin( 'ai-contextual-tagging-tags', { - render: TagPanelWrapper, -} ); - -// Register plugin for category suggestions in the Categories sidebar panel. -registerPlugin( 'ai-contextual-tagging-categories', { - render: CategoryPanelWrapper, -} ); +import SuggestionPanel from './components/SuggestionPanel'; + +const SUPPORTED_TAXONOMIES = [ 'post_tag', 'category' ]; + +/** + * Wraps the taxonomy selector component with the AI suggestion panel. + * + * @param OriginalComponent The original taxonomy selector component. + * @return The wrapped component. + */ +function withContextualTagging( + OriginalComponent: React.ComponentType< any > +) { + return function ContextualTaggingWrapper( props: any ) { + const { slug } = props; + + if ( ! SUPPORTED_TAXONOMIES.includes( slug ) ) { + return ; + } + + return ( + <> + + + + ); + }; +} + +addFilter( + 'editor.PostTaxonomyType', + 'ai/contextual-tagging', + withContextualTagging +); From 46d15021237321335e357cb093f3638714b903fb Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 13:11:58 -0400 Subject: [PATCH 07/72] use core wordcount package to count the post content words. --- .../contextual-tagging/components/useContextualTagging.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/experiments/contextual-tagging/components/useContextualTagging.ts b/src/experiments/contextual-tagging/components/useContextualTagging.ts index f39fa3632..e1b161f9e 100644 --- a/src/experiments/contextual-tagging/components/useContextualTagging.ts +++ b/src/experiments/contextual-tagging/components/useContextualTagging.ts @@ -11,6 +11,7 @@ import { store as editorStore } from '@wordpress/editor'; import { useState, useCallback } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import apiFetch from '@wordpress/api-fetch'; +import { count as wordCount } from '@wordpress/wordcount'; /** * Internal dependencies @@ -91,11 +92,8 @@ export function useContextualTagging( taxonomy: string ): { const [ suggestions, setSuggestions ] = useState< TagSuggestion[] >( [] ); // Check if content has enough words. - const wordCount = content - ? content.replace( /<[^>]*>/g, '' ).split( /\s+/ ).filter( Boolean ) - .length - : 0; - const hasEnoughContent = wordCount >= MINIMUM_WORD_COUNT; + const hasEnoughContent = + wordCount( content || '', 'words' ) >= MINIMUM_WORD_COUNT; const handleGenerate = useCallback( async () => { const settings = getSettings(); From f1d2e52947e6d07edb964c30249d26c7e5011c10 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 13:48:29 -0400 Subject: [PATCH 08/72] Use taxonomy object to retrieve labels. --- .../components/SuggestionPanel.tsx | 107 ++++++++---------- .../components/useContextualTagging.ts | 92 +++++++-------- .../block-editor-augmentation.d.ts | 8 +- 3 files changed, 89 insertions(+), 118 deletions(-) diff --git a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx index 5a1d55a20..5fe705167 100644 --- a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx +++ b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx @@ -5,12 +5,9 @@ /** * WordPress dependencies */ -import { - Button, - Flex, - FlexItem, - Spinner, -} from '@wordpress/components'; +import { Button, Flex, FlexItem, Spinner } from '@wordpress/components'; +import { select } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; import { __, sprintf } from '@wordpress/i18n'; import { close as closeIcon, update } from '@wordpress/icons'; @@ -46,10 +43,8 @@ export default function SuggestionPanel( { handleDismissAll, } = useContextualTagging( taxonomy ); - const taxonomyLabel = - taxonomy === 'category' - ? __( 'Categories', 'ai' ) - : __( 'Tags', 'ai' ); + const taxonomyObject: any = select( coreStore ).getTaxonomy( taxonomy ); + const taxonomyLabel: string = taxonomyObject?.name ?? taxonomy; const hasSuggestions = suggestions.length > 0; @@ -93,60 +88,48 @@ export default function SuggestionPanel( { { hasSuggestions && (
- { suggestions.map( - ( suggestion: TagSuggestion ) => ( - ( + + - +
- + From b2cb0891fd6f3c3f0439cc347a56ad7cbb0d6e42 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 11:42:11 -0400 Subject: [PATCH 29/72] Register new Contextual Tagging experiment. Create editor UI and unit tests. --- .../Contextual_Tagging/Contextual_Tagging.php | 489 ++++++++++++++++++ .../Contextual_Tagging/system-instruction.php | 42 ++ .../Contextual_Tagging/Contextual_Tagging.php | 236 +++++++++ .../components/CategoryPanelWrapper.tsx | 118 +++++ .../components/SuggestionPanel.tsx | 153 ++++++ .../components/TagPanelWrapper.tsx | 118 +++++ .../components/useContextualTagging.ts | 250 +++++++++ src/experiments/contextual-tagging/index.tsx | 24 + src/experiments/contextual-tagging/types.ts | 41 ++ .../Abilities/Contextual_TaggingTest.php | 403 +++++++++++++++ .../Contextual_TaggingTest.php | 121 +++++ webpack.config.js | 5 + 12 files changed, 2000 insertions(+) create mode 100644 includes/Abilities/Contextual_Tagging/Contextual_Tagging.php create mode 100644 includes/Abilities/Contextual_Tagging/system-instruction.php create mode 100644 includes/Experiments/Contextual_Tagging/Contextual_Tagging.php create mode 100644 src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx create mode 100644 src/experiments/contextual-tagging/components/SuggestionPanel.tsx create mode 100644 src/experiments/contextual-tagging/components/TagPanelWrapper.tsx create mode 100644 src/experiments/contextual-tagging/components/useContextualTagging.ts create mode 100644 src/experiments/contextual-tagging/index.tsx create mode 100644 src/experiments/contextual-tagging/types.ts create mode 100644 tests/Integration/Includes/Abilities/Contextual_TaggingTest.php create mode 100644 tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php diff --git a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php new file mode 100644 index 000000000..822eafbdf --- /dev/null +++ b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php @@ -0,0 +1,489 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Content to generate taxonomy suggestions for.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Content from this post will be used to generate taxonomy suggestions. This overrides the content parameter if both are provided.', 'ai' ), + ), + 'taxonomy' => array( + 'type' => 'string', + 'default' => 'post_tag', + 'sanitize_callback' => 'sanitize_key', + 'description' => esc_html__( 'The taxonomy to generate suggestions for (e.g., post_tag, category).', 'ai' ), + ), + 'strategy' => array( + 'type' => 'string', + 'default' => 'existing_only', + 'sanitize_callback' => 'sanitize_key', + 'description' => esc_html__( 'The suggestion strategy: existing_only or allow_new.', 'ai' ), + ), + 'max_suggestions' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 10, + 'default' => self::SUGGESTIONS_DEFAULT, + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Maximum number of suggestions to generate.', 'ai' ), + ), + ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.6.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'suggestions' => array( + 'type' => 'array', + 'description' => esc_html__( 'Generated taxonomy term suggestions.', 'ai' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'term' => array( + 'type' => 'string', + 'description' => esc_html__( 'The suggested term name.', 'ai' ), + ), + 'confidence' => array( + 'type' => 'number', + 'description' => esc_html__( 'Confidence score between 0 and 1.', 'ai' ), + ), + 'is_new' => array( + 'type' => 'boolean', + 'description' => esc_html__( 'Whether this is a new term or an existing one.', 'ai' ), + ), + 'parent' => array( + 'type' => 'string', + 'description' => esc_html__( 'Parent term name for hierarchical taxonomies.', 'ai' ), + ), + ), + ), + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @since 0.6.0 + * + * @param mixed $input The input arguments to the ability. + * @return array{suggestions: array}|\WP_Error The result of the ability execution, or a WP_Error on failure. + */ + protected function execute_callback( $input ) { + // Default arguments. + $args = wp_parse_args( + $input, + array( + 'content' => null, + 'post_id' => null, + 'taxonomy' => 'post_tag', + 'strategy' => 'existing_only', + 'max_suggestions' => self::SUGGESTIONS_DEFAULT, + ), + ); + + // Validate taxonomy. + if ( ! taxonomy_exists( $args['taxonomy'] ) ) { + return new WP_Error( + 'invalid_taxonomy', + /* translators: %s: Taxonomy name. */ + sprintf( esc_html__( 'Taxonomy "%s" does not exist.', 'ai' ), sanitize_key( $args['taxonomy'] ) ) + ); + } + + // If a post ID is provided, ensure the post exists before using its content. + if ( $args['post_id'] ) { + $post = get_post( (int) $args['post_id'] ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + // Get the post context. + $context = get_post_context( (int) $args['post_id'] ); + + // Default to the passed in content if it exists. + if ( $args['content'] ) { + $context['content'] = normalize_content( $args['content'] ); + } + } else { + $context = array( + 'content' => normalize_content( $args['content'] ?? '' ), + ); + } + + // If we have no content, return an error. + if ( empty( $context['content'] ) ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'Content is required to generate taxonomy suggestions.', 'ai' ) + ); + } + + // Generate the suggestions. + $result = $this->generate_suggestions( + $context, + $args['taxonomy'], + $args['strategy'], + (int) $args['max_suggestions'] + ); + + // If we have an error, return it. + if ( is_wp_error( $result ) ) { + return $result; + } + + // If we have no results, return an error. + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No taxonomy suggestions were generated.', 'ai' ) + ); + } + + return array( + 'suggestions' => $result, + ); + } + + /** + * Returns the permission callback of the ability. + * + * @since 0.6.0 + * + * @param mixed $args The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + protected function permission_callback( $args ) { + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : null; + + if ( $post_id ) { + $post = get_post( $args['post_id'] ); + + // Ensure the post exists. + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + // Ensure the user has permission to edit this particular post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate taxonomy suggestions for this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type = get_post_type( $post_id ); + + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + // Ensure the user has permission to edit posts in general. + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate taxonomy suggestions.', 'ai' ) + ); + } + + return true; + } + + /** + * Returns the meta of the ability. + * + * @since 0.6.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generates taxonomy term suggestions from the given content. + * + * @since 0.6.0 + * + * @param string|array $context The context to generate suggestions from. + * @param string $taxonomy The taxonomy to suggest terms for. + * @param string $strategy The suggestion strategy. + * @param int $max_suggestions The maximum number of suggestions. + * @return array|\WP_Error The generated suggestions, or a WP_Error if there was an error. + */ + protected function generate_suggestions( $context, string $taxonomy, string $strategy, int $max_suggestions ) { + // Convert the context to a string if it's an array. + if ( is_array( $context ) ) { + $context = implode( + "\n", + array_map( + static function ( $key, $value ) { + return sprintf( + '%s: %s', + ucwords( str_replace( '_', ' ', $key ) ), + $value + ); + }, + array_keys( $context ), + $context + ) + ); + } + + // Fetch existing terms for the taxonomy. + $existing_terms = $this->get_existing_terms( $taxonomy ); + + // Build strategy instruction. + $strategy_instruction = $this->build_strategy_instruction( $strategy ); + + // Build existing terms instruction. + $existing_terms_instruction = $this->build_existing_terms_instruction( $existing_terms, $strategy ); + + // Get the taxonomy label for the prompt. + $taxonomy_label = $this->get_taxonomy_label( $taxonomy ); + + // Generate the suggestions using the AI client. + $result = wp_ai_client_prompt( '"""' . $context . '"""' ) + ->using_system_instruction( + $this->get_system_instruction( + null, + array( + 'strategy' => $strategy_instruction, + 'max_suggestions' => $max_suggestions, + 'taxonomy' => $taxonomy_label, + 'existing_terms' => $existing_terms_instruction, + ) + ) + ) + ->using_temperature( 0.5 ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Parse the JSON response. + return $this->parse_suggestions( $result, $existing_terms, $max_suggestions ); + } + + /** + * Gets existing terms for a taxonomy. + * + * @since 0.6.0 + * + * @param string $taxonomy The taxonomy to get terms for. + * @return array List of existing term names. + */ + protected function get_existing_terms( string $taxonomy ): array { + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'fields' => 'names', + ) + ); + + if ( is_wp_error( $terms ) ) { + return array(); + } + + return (array) $terms; + } + + /** + * Builds the strategy instruction for the system prompt. + * + * @since 0.6.0 + * + * @param string $strategy The suggestion strategy. + * @return string The strategy instruction text. + */ + protected function build_strategy_instruction( string $strategy ): string { + if ( 'existing_only' === $strategy ) { + return '- IMPORTANT: Only suggest terms that already exist on the site. Set "is_new" to false for all suggestions. Do not invent new terms.'; + } + + return '- You may suggest new terms if no good existing match exists. Set "is_new" to true for new terms and false for existing terms. Prefer existing terms when possible.'; + } + + /** + * Builds the existing terms instruction for the system prompt. + * + * @since 0.6.0 + * + * @param array $existing_terms The existing terms. + * @param string $strategy The suggestion strategy. + * @return string The existing terms instruction text. + */ + protected function build_existing_terms_instruction( array $existing_terms, string $strategy ): string { + if ( empty( $existing_terms ) ) { + if ( 'existing_only' === $strategy ) { + return '- No existing terms are available. Return an empty array.'; + } + + return '- No existing terms are available. You may suggest new terms.'; + } + + return sprintf( + "- Existing terms on the site: %s\n- Prioritize selecting from these existing terms.", + implode( ', ', $existing_terms ) + ); + } + + /** + * Gets a human-readable label for the taxonomy. + * + * @since 0.6.0 + * + * @param string $taxonomy The taxonomy slug. + * @return string The taxonomy label. + */ + protected function get_taxonomy_label( string $taxonomy ): string { + $taxonomy_obj = get_taxonomy( $taxonomy ); + + if ( $taxonomy_obj ) { + return strtolower( $taxonomy_obj->labels->name ); + } + + return $taxonomy; + } + + /** + * Parses the AI response into structured suggestions. + * + * @since 0.6.0 + * + * @param string $response The raw AI response. + * @param array $existing_terms List of existing term names. + * @param int $max_suggestions The maximum number of suggestions. + * @return array|\WP_Error Parsed suggestions or error. + */ + protected function parse_suggestions( string $response, array $existing_terms, int $max_suggestions ) { + // Strip any markdown code fences the model may have included. + $response = trim( $response ); + $response = preg_replace( '/^```(?:json)?\s*/i', '', $response ) ?? $response; + $response = preg_replace( '/\s*```$/', '', $response ) ?? $response; + $response = trim( $response ); + + $decoded = json_decode( $response, true ); + + if ( ! is_array( $decoded ) ) { + return new WP_Error( + 'invalid_response', + esc_html__( 'Could not parse AI response as valid suggestions.', 'ai' ) + ); + } + + $existing_terms_lower = array_map( 'strtolower', $existing_terms ); + $suggestions = array(); + + foreach ( $decoded as $item ) { + if ( ! is_array( $item ) || empty( $item['term'] ) ) { + continue; + } + + $term = sanitize_text_field( trim( $item['term'] ) ); + $confidence = isset( $item['confidence'] ) ? (float) $item['confidence'] : 0.5; + $is_new = ! in_array( strtolower( $term ), $existing_terms_lower, true ); + + $suggestion = array( + 'term' => $term, + 'confidence' => max( 0.0, min( 1.0, $confidence ) ), + 'is_new' => $is_new, + ); + + if ( ! empty( $item['parent'] ) ) { + $suggestion['parent'] = sanitize_text_field( trim( $item['parent'] ) ); + } + + $suggestions[] = $suggestion; + } + + // Sort by confidence descending. + usort( + $suggestions, + static function ( $a, $b ) { + return $b['confidence'] <=> $a['confidence']; + } + ); + + // Limit to max suggestions. + return array_slice( $suggestions, 0, $max_suggestions ); + } +} diff --git a/includes/Abilities/Contextual_Tagging/system-instruction.php b/includes/Abilities/Contextual_Tagging/system-instruction.php new file mode 100644 index 000000000..c289b5fc6 --- /dev/null +++ b/includes/Abilities/Contextual_Tagging/system-instruction.php @@ -0,0 +1,42 @@ + 'contextual-tagging', + 'label' => __( 'Contextual Tagging', 'ai' ), + 'description' => __( 'AI-powered suggestions for post tags and categories based on content analysis.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + * + * @since 0.6.0 + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers any needed abilities. + * + * @since 0.6.0 + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Contextual_Tagging_Ability::class, + ), + ); + } + + /** + * Enqueues and localizes the admin script. + * + * @since 0.6.0 + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + // Load asset in new post and edit post screens only. + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + $screen = get_current_screen(); + + // Load the assets only if the post type supports editor and is not an attachment. + if ( + ! $screen || + ! post_type_supports( $screen->post_type, 'editor' ) || + in_array( $screen->post_type, array( 'attachment' ), true ) + ) { + return; + } + + Asset_Loader::enqueue_script( 'contextual_tagging', 'experiments/contextual-tagging' ); + Asset_Loader::localize_script( + 'contextual_tagging', + 'ContextualTaggingData', + array( + 'enabled' => $this->is_enabled(), + 'strategy' => get_option( $this->get_field_option_name( 'strategy' ), self::STRATEGY_EXISTING_ONLY ), + 'maxSuggestions' => (int) get_option( $this->get_field_option_name( 'max_suggestions' ), self::DEFAULT_MAX_SUGGESTIONS ), + ) + ); + } + + /** + * Registers experiment-specific settings. + * + * @since 0.6.0 + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'strategy' ), + array( + 'type' => 'string', + 'default' => self::STRATEGY_EXISTING_ONLY, + 'sanitize_callback' => array( $this, 'sanitize_strategy' ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'max_suggestions' ), + array( + 'type' => 'integer', + 'default' => self::DEFAULT_MAX_SUGGESTIONS, + 'sanitize_callback' => array( $this, 'sanitize_max_suggestions' ), + ) + ); + } + + /** + * Renders experiment-specific settings fields. + * + * @since 0.6.0 + */ + public function render_settings_fields(): void { + $strategy_option = $this->get_field_option_name( 'strategy' ); + $max_suggestions_option = $this->get_field_option_name( 'max_suggestions' ); + $current_strategy = get_option( $strategy_option, self::STRATEGY_EXISTING_ONLY ); + $current_max = get_option( $max_suggestions_option, self::DEFAULT_MAX_SUGGESTIONS ); + ?> +
+ +

+ + +

+

+ + +

+
+ ( + null + ); + + useEffect( () => { + const findAndAttach = (): boolean => { + // Don't create duplicate containers. + if ( document.getElementById( CONTAINER_ID ) ) { + return true; + } + + // Find the Categories panel by its toggle button text. + const categoriesPanel = findPanelByTitle( 'Categories' ); + + if ( ! categoriesPanel ) { + return false; + } + + // Create and inject our container at the end of the panel. + const el = document.createElement( 'div' ); + el.id = CONTAINER_ID; + categoriesPanel.appendChild( el ); + setContainer( el ); + return true; + }; + + // Try immediately. + if ( findAndAttach() ) { + return; + } + + // Observe for the panel appearing. + const observer = new MutationObserver( () => { + if ( findAndAttach() ) { + observer.disconnect(); + } + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + return () => { + observer.disconnect(); + const el = document.getElementById( CONTAINER_ID ); + if ( el ) { + el.remove(); + } + }; + }, [] ); + + useEffect( () => { + if ( ! container ) { + return; + } + + const root = createRoot( container ); + root.render( ); + + return () => { + root.unmount(); + }; + }, [ container ] ); + + return null; +} diff --git a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx new file mode 100644 index 000000000..963e5cd97 --- /dev/null +++ b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx @@ -0,0 +1,153 @@ +/** + * Suggestion panel component for displaying AI-generated taxonomy suggestions. + */ + +/** + * WordPress dependencies + */ +import { Button, Spinner } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { close as closeIcon, update } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { useContextualTagging } from './useContextualTagging'; +import type { TagSuggestion } from '../types'; + +interface SuggestionPanelProps { + taxonomy: string; +} + +/** + * SuggestionPanel component. + * + * Displays a button to generate suggestions and renders suggestion pills + * that can be accepted or dismissed. + * + * @param props Component props. + * @return The suggestion panel component. + */ +export default function SuggestionPanel( { + taxonomy, +}: SuggestionPanelProps ): JSX.Element | null { + const { + isGenerating, + suggestions, + hasEnoughContent, + handleGenerate, + handleAccept, + handleDismiss, + handleDismissAll, + } = useContextualTagging( taxonomy ); + + const taxonomyLabel = + taxonomy === 'category' + ? __( 'Categories', 'ai' ) + : __( 'Tags', 'ai' ); + + const hasSuggestions = suggestions.length > 0; + + return ( +
+ { ! hasSuggestions && ( + + ) } + + { ! hasEnoughContent && ! hasSuggestions && ( +

+ { __( + 'Add more content to enable AI suggestions (approximately 150 words).', + 'ai' + ) } +

+ ) } + + { isGenerating && ( +
+ +
+ ) } + + { hasSuggestions && ( +
+
+ { suggestions.map( ( suggestion: TagSuggestion ) => ( + + +
+
+ + +
+
+ ) } +
+ ); +} diff --git a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx new file mode 100644 index 000000000..4a0518ace --- /dev/null +++ b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx @@ -0,0 +1,118 @@ +/** + * Wrapper component that injects AI suggestions into the Tags sidebar panel. + */ + +/** + * WordPress dependencies + */ +import { useEffect, useState, createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SuggestionPanel from './SuggestionPanel'; + +/** + * Container ID for the tag suggestions. + */ +const CONTAINER_ID = 'ai-contextual-tagging-tags'; + +/** + * Finds a sidebar panel by its toggle button text. + * + * @param title The panel title text to search for. + * @return The panel body element, or null if not found. + */ +function findPanelByTitle( title: string ): HTMLElement | null { + const panelBodies = document.querySelectorAll( + '.components-panel__body' + ); + + for ( const panel of panelBodies ) { + const toggle = panel.querySelector( + '.components-panel__body-toggle' + ); + if ( toggle?.textContent?.trim() === title ) { + return panel as HTMLElement; + } + } + + return null; +} + +/** + * TagPanelWrapper component. + * + * Uses DOM observation to inject the suggestion panel into the Tags + * sidebar panel in the block editor. + * + * @return null - Renders via portal. + */ +export default function TagPanelWrapper(): null { + const [ container, setContainer ] = useState< HTMLElement | null >( + null + ); + + useEffect( () => { + const findAndAttach = (): boolean => { + // Don't create duplicate containers. + if ( document.getElementById( CONTAINER_ID ) ) { + return true; + } + + // Find the Tags panel by its toggle button text. + const tagsPanel = findPanelByTitle( 'Tags' ); + + if ( ! tagsPanel ) { + return false; + } + + // Create and inject our container at the end of the panel. + const el = document.createElement( 'div' ); + el.id = CONTAINER_ID; + tagsPanel.appendChild( el ); + setContainer( el ); + return true; + }; + + // Try immediately. + if ( findAndAttach() ) { + return; + } + + // Observe for the panel appearing. + const observer = new MutationObserver( () => { + if ( findAndAttach() ) { + observer.disconnect(); + } + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + return () => { + observer.disconnect(); + const el = document.getElementById( CONTAINER_ID ); + if ( el ) { + el.remove(); + } + }; + }, [] ); + + useEffect( () => { + if ( ! container ) { + return; + } + + const root = createRoot( container ); + root.render( ); + + return () => { + root.unmount(); + }; + }, [ container ] ); + + return null; +} diff --git a/src/experiments/contextual-tagging/components/useContextualTagging.ts b/src/experiments/contextual-tagging/components/useContextualTagging.ts new file mode 100644 index 000000000..f39fa3632 --- /dev/null +++ b/src/experiments/contextual-tagging/components/useContextualTagging.ts @@ -0,0 +1,250 @@ +/** + * Shared hook for contextual tagging logic. + */ + +/** + * WordPress dependencies + */ +import { dispatch, resolveSelect, select } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState, useCallback } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import type { + ContextualTaggingAbilityInput, + ContextualTaggingResponse, + TagSuggestion, + ContextualTaggingData, +} from '../types'; + +const MINIMUM_WORD_COUNT = 150; +const NOTICE_ID = 'ai_contextual_tagging_error'; + +const getSettings = (): ContextualTaggingData => + ( window as any ).aiContextualTaggingData ?? { + enabled: false, + strategy: 'existing_only', + maxSuggestions: 5, + }; + +/** + * Generates taxonomy suggestions for the given post. + * + * @param postId The post ID. + * @param content The post content. + * @param taxonomy The taxonomy to suggest terms for. + * @param strategy The suggestion strategy. + * @param maxSuggestions The maximum number of suggestions. + * @return A promise that resolves to the generated suggestions. + */ +async function generateSuggestions( + postId: number, + content: string, + taxonomy: string, + strategy: string, + maxSuggestions: number +): Promise< TagSuggestion[] > { + const params: ContextualTaggingAbilityInput = { + content, + post_id: postId, + taxonomy, + strategy, + max_suggestions: maxSuggestions, + }; + + const response = await runAbility< ContextualTaggingResponse >( + 'ai/contextual-tagging', + params + ); + + if ( response?.suggestions && Array.isArray( response.suggestions ) ) { + return response.suggestions; + } + + return []; +} + +/** + * Hook for contextual tagging functionality. + * + * @param taxonomy The taxonomy to generate suggestions for. + * @return Object with generation state, suggestions, and handlers. + */ +export function useContextualTagging( taxonomy: string ): { + isGenerating: boolean; + suggestions: TagSuggestion[]; + hasEnoughContent: boolean; + handleGenerate: () => Promise< void >; + handleAccept: ( suggestion: TagSuggestion ) => void; + handleDismiss: ( suggestion: TagSuggestion ) => void; + handleDismissAll: () => void; +} { + const postId = select( editorStore ).getCurrentPostId() as number; + const content = select( editorStore ).getEditedPostContent(); + const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); + const [ suggestions, setSuggestions ] = useState< TagSuggestion[] >( [] ); + + // Check if content has enough words. + const wordCount = content + ? content.replace( /<[^>]*>/g, '' ).split( /\s+/ ).filter( Boolean ) + .length + : 0; + const hasEnoughContent = wordCount >= MINIMUM_WORD_COUNT; + + const handleGenerate = useCallback( async () => { + const settings = getSettings(); + setIsGenerating( true ); + setSuggestions( [] ); + ( dispatch( noticesStore ) as any ).removeNotice( NOTICE_ID ); + + try { + const result = await generateSuggestions( + postId, + content, + taxonomy, + settings.strategy, + settings.maxSuggestions + ); + setSuggestions( result ); + } catch ( error: any ) { + ( dispatch( noticesStore ) as any ).createErrorNotice( + error?.message || error, + { + id: NOTICE_ID, + isDismissible: true, + } + ); + } finally { + setIsGenerating( false ); + } + }, [ postId, content, taxonomy ] ); + + const handleAccept = useCallback( + ( suggestion: TagSuggestion ) => { + // Remove from suggestions list. + setSuggestions( ( prev ) => + prev.filter( ( s ) => s.term !== suggestion.term ) + ); + + // Add the term to the post. + addTermToPost( taxonomy, suggestion ); + }, + [ taxonomy ] + ); + + const handleDismiss = useCallback( ( suggestion: TagSuggestion ) => { + setSuggestions( ( prev ) => + prev.filter( ( s ) => s.term !== suggestion.term ) + ); + }, [] ); + + const handleDismissAll = useCallback( () => { + setSuggestions( [] ); + }, [] ); + + return { + isGenerating, + suggestions, + hasEnoughContent, + handleGenerate, + handleAccept, + handleDismiss, + handleDismissAll, + }; +} + +/** + * Adds a term to the current post. + * + * @param taxonomy The taxonomy slug. + * @param suggestion The suggestion to add. + */ +async function addTermToPost( + taxonomy: string, + suggestion: TagSuggestion +): Promise< void > { + const { editPost } = dispatch( editorStore ); + + if ( taxonomy === 'post_tag' ) { + // For tags, we can use the tag name directly via the editor store. + // WordPress handles creating new tags automatically when saving. + const currentTags: string[] = + ( select( editorStore ) as any ).getEditedPostAttribute( 'tags' ) ?? + []; + + // Look up the term by name to get its ID, or create it. + const termId = await findOrCreateTerm( taxonomy, suggestion.term ); + + if ( termId && ! currentTags.includes( termId as any ) ) { + ( editPost as any )( { + tags: [ ...currentTags, termId ], + } ); + } + } else if ( taxonomy === 'category' ) { + const currentCategories: number[] = + ( select( editorStore ) as any ).getEditedPostAttribute( + 'categories' + ) ?? []; + + const termId = await findOrCreateTerm( taxonomy, suggestion.term ); + + if ( termId && ! currentCategories.includes( termId ) ) { + ( editPost as any )( { + categories: [ ...currentCategories, termId ], + } ); + } + } +} + +/** + * Finds an existing term by name or creates a new one. + * + * @param taxonomy The taxonomy slug. + * @param termName The term name. + * @return The term ID, or null if not found and could not be created. + */ +async function findOrCreateTerm( + taxonomy: string, + termName: string +): Promise< number | null > { + // Map taxonomy slug to REST base. + const restBase = taxonomy === 'post_tag' ? 'tags' : 'categories'; + + try { + // Search for existing term. + const searchResults: any[] = await ( + resolveSelect( coreStore ) as any + ).getEntityRecords( 'taxonomy', restBase, { + search: termName, + per_page: 100, + } ); + + // If we have a direct match, return its ID. + if ( Array.isArray( searchResults ) ) { + const match = searchResults.find( + ( t: any ) => + t.name.toLowerCase() === termName.toLowerCase() + ); + if ( match ) { + return match.id; + } + } + + // Create new term via REST. + const newTerm: any = await apiFetch( { + path: `/wp/v2/${ restBase }`, + method: 'POST', + data: { name: termName }, + } ); + + return newTerm?.id ?? null; + } catch { + return null; + } +} diff --git a/src/experiments/contextual-tagging/index.tsx b/src/experiments/contextual-tagging/index.tsx new file mode 100644 index 000000000..ad7c300f6 --- /dev/null +++ b/src/experiments/contextual-tagging/index.tsx @@ -0,0 +1,24 @@ +/** + * Contextual tagging experiment plugin registration. + */ + +/** + * WordPress dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import TagPanelWrapper from './components/TagPanelWrapper'; +import CategoryPanelWrapper from './components/CategoryPanelWrapper'; + +// Register plugin for tag suggestions in the Tags sidebar panel. +registerPlugin( 'ai-contextual-tagging-tags', { + render: TagPanelWrapper, +} ); + +// Register plugin for category suggestions in the Categories sidebar panel. +registerPlugin( 'ai-contextual-tagging-categories', { + render: CategoryPanelWrapper, +} ); diff --git a/src/experiments/contextual-tagging/types.ts b/src/experiments/contextual-tagging/types.ts new file mode 100644 index 000000000..d355963c0 --- /dev/null +++ b/src/experiments/contextual-tagging/types.ts @@ -0,0 +1,41 @@ +/** + * Type definitions for contextual tagging experiment. + */ + +/** + * Input parameters for the ai/contextual-tagging ability. + */ +export interface ContextualTaggingAbilityInput { + content: string; + post_id: number; + taxonomy: string; + strategy: string; + max_suggestions: number; + [ key: string ]: string | number | undefined; +} + +/** + * A single taxonomy term suggestion from the AI. + */ +export interface TagSuggestion { + term: string; + confidence: number; + is_new: boolean; + parent?: string; +} + +/** + * Response from the ai/contextual-tagging ability. + */ +export interface ContextualTaggingResponse { + suggestions: TagSuggestion[]; +} + +/** + * Localized data from the PHP side. + */ +export interface ContextualTaggingData { + enabled: boolean; + strategy: string; + maxSuggestions: number; +} diff --git a/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php b/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php new file mode 100644 index 000000000..0606697d1 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php @@ -0,0 +1,403 @@ + 'contextual-tagging', + 'label' => 'Contextual Tagging', + 'description' => 'AI-powered suggestions for post tags and categories.', + ); + } + + /** + * Registers the experiment. + * + * @since 0.6.0 + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Contextual_Tagging Ability test case. + * + * @since 0.6.0 + */ +class Contextual_TaggingTest extends WP_UnitTestCase { + + /** + * Contextual_Tagging ability instance. + * + * @var \WordPress\AI\Abilities\Contextual_Tagging\Contextual_Tagging + */ + private $ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Contextual_Tagging_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since 0.6.0 + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Contextual_Tagging_Experiment(); + $this->ability = new Contextual_Tagging( + 'ai/contextual-tagging', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since 0.6.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since 0.6.0 + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since 0.6.0 + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); + $this->assertArrayHasKey( 'post_id', $schema['properties'], 'Schema should have post_id property' ); + $this->assertArrayHasKey( 'taxonomy', $schema['properties'], 'Schema should have taxonomy property' ); + $this->assertArrayHasKey( 'strategy', $schema['properties'], 'Schema should have strategy property' ); + $this->assertArrayHasKey( 'max_suggestions', $schema['properties'], 'Schema should have max_suggestions property' ); + + // Verify taxonomy property. + $this->assertEquals( 'string', $schema['properties']['taxonomy']['type'], 'Taxonomy should be string type' ); + $this->assertEquals( 'post_tag', $schema['properties']['taxonomy']['default'], 'Taxonomy default should be post_tag' ); + + // Verify strategy property. + $this->assertEquals( 'string', $schema['properties']['strategy']['type'], 'Strategy should be string type' ); + $this->assertEquals( 'existing_only', $schema['properties']['strategy']['default'], 'Strategy default should be existing_only' ); + + // Verify max_suggestions property. + $this->assertEquals( 'integer', $schema['properties']['max_suggestions']['type'], 'max_suggestions should be integer type' ); + $this->assertEquals( 1, $schema['properties']['max_suggestions']['minimum'], 'max_suggestions minimum should be 1' ); + $this->assertEquals( 10, $schema['properties']['max_suggestions']['maximum'], 'max_suggestions maximum should be 10' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since 0.6.0 + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'suggestions', $schema['properties'], 'Schema should have suggestions property' ); + $this->assertEquals( 'array', $schema['properties']['suggestions']['type'], 'Suggestions should be array type' ); + $this->assertArrayHasKey( 'items', $schema['properties']['suggestions'], 'Suggestions should have items' ); + + // Verify suggestion item properties. + $item_props = $schema['properties']['suggestions']['items']['properties']; + $this->assertArrayHasKey( 'term', $item_props, 'Item should have term property' ); + $this->assertArrayHasKey( 'confidence', $item_props, 'Item should have confidence property' ); + $this->assertArrayHasKey( 'is_new', $item_props, 'Item should have is_new property' ); + $this->assertArrayHasKey( 'parent', $item_props, 'Item should have parent property' ); + } + + /** + * Test that get_system_instruction() returns the system instruction. + * + * @since 0.6.0 + */ + public function test_get_system_instruction_returns_system_instruction() { + $system_instruction = $this->ability->get_system_instruction( + null, + array( + 'strategy' => 'Only suggest existing terms.', + 'max_suggestions' => 5, + 'taxonomy' => 'tags', + 'existing_terms' => 'Existing terms: wordpress, plugins', + ) + ); + + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); + $this->assertStringContainsString( 'tags', $system_instruction, 'System instruction should contain the taxonomy name' ); + } + + /** + * Test that execute_callback() returns error when content is missing. + * + * @since 0.6.0 + */ + public function test_execute_callback_without_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array(); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() returns error when post_id points to non-existent post. + * + * @since 0.6.0 + */ + public function test_execute_callback_with_invalid_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'post_id' => 99999, // Non-existent post ID. + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that execute_callback() returns error for invalid taxonomy. + * + * @since 0.6.0 + */ + public function test_execute_callback_with_invalid_taxonomy() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'Test content for taxonomy suggestions.', + 'taxonomy' => 'nonexistent_taxonomy', + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'invalid_taxonomy', $result->get_error_code(), 'Error code should be invalid_taxonomy' ); + } + + /** + * Test that parse_suggestions() handles valid JSON correctly. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_with_valid_json() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = '[{"term": "development", "confidence": 0.9, "is_new": false}, {"term": "plugins", "confidence": 0.8, "is_new": true}]'; + + $result = $method->invoke( $this->ability, $response, array( 'development' ), 5 ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertCount( 2, $result, 'Should have 2 suggestions' ); + $this->assertEquals( 'development', $result[0]['term'], 'First suggestion should be development' ); + $this->assertFalse( $result[0]['is_new'], 'Existing term should not be marked as new' ); + $this->assertTrue( $result[1]['is_new'], 'Non-existing term should be marked as new' ); + } + + /** + * Test that parse_suggestions() handles markdown-wrapped JSON. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_with_markdown_json() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = "```json\n[{\"term\": \"ai\", \"confidence\": 0.95, \"is_new\": false}]\n```"; + + $result = $method->invoke( $this->ability, $response, array( 'ai' ), 5 ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertCount( 1, $result, 'Should have 1 suggestion' ); + $this->assertEquals( 'ai', $result[0]['term'], 'Suggestion should be ai' ); + } + + /** + * Test that parse_suggestions() returns error for invalid JSON. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_with_invalid_json() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = 'This is not valid JSON'; + + $result = $method->invoke( $this->ability, $response, array(), 5 ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'invalid_response', $result->get_error_code(), 'Error code should be invalid_response' ); + } + + /** + * Test that parse_suggestions() limits results to max_suggestions. + * + * @since 0.6.0 + */ + public function test_parse_suggestions_limits_results() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = '[ + {"term": "a", "confidence": 0.9, "is_new": false}, + {"term": "b", "confidence": 0.8, "is_new": false}, + {"term": "c", "confidence": 0.7, "is_new": false}, + {"term": "d", "confidence": 0.6, "is_new": false}, + {"term": "e", "confidence": 0.5, "is_new": false} + ]'; + + $result = $method->invoke( $this->ability, $response, array(), 3 ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertCount( 3, $result, 'Should be limited to 3 suggestions' ); + $this->assertEquals( 'a', $result[0]['term'], 'First suggestion should be highest confidence' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since 0.6.0 + */ + public function test_permission_callback_with_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + * + * @since 0.6.0 + */ + public function test_permission_callback_without_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns error for logged out user. + * + * @since 0.6.0 + */ + public function test_permission_callback_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that meta() returns the expected meta structure. + * + * @since 0.6.0 + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } +} diff --git a/tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php b/tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php new file mode 100644 index 000000000..4c1540886 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php @@ -0,0 +1,121 @@ + 'test-api-key' ) ); + + // Mock has_valid_ai_credentials to return true for tests. + add_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' ); + + // Enable experiments globally and individually. + update_option( 'ai_experiments_enabled', true ); + update_option( 'ai_experiment_contextual-tagging_enabled', true ); + + $registry = new Experiment_Registry(); + $loader = new Experiment_Loader( $registry ); + $loader->register_default_experiments(); + + $experiment = $registry->get_experiment( 'contextual-tagging' ); + $this->assertInstanceOf( Contextual_Tagging::class, $experiment, 'Contextual tagging experiment should be registered in the registry.' ); + } + + /** + * Tear down test case. + * + * @since 0.6.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'ai_experiments_enabled' ); + delete_option( 'ai_experiment_contextual-tagging_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Test that the experiment is registered correctly. + * + * @since 0.6.0 + */ + public function test_experiment_registration() { + $experiment = new Contextual_Tagging(); + + $this->assertEquals( 'contextual-tagging', $experiment->get_id() ); + $this->assertEquals( 'Contextual Tagging', $experiment->get_label() ); + $this->assertEquals( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that experiment settings are registered. + * + * @since 0.6.0 + */ + public function test_experiment_settings_registration() { + $experiment = new Contextual_Tagging(); + $experiment->register_settings(); + + // Verify the settings are registered by checking they can be retrieved. + $strategy = get_option( 'ai_experiment_contextual-tagging_field_strategy', 'existing_only' ); + $this->assertEquals( 'existing_only', $strategy ); + + $max_suggestions = get_option( 'ai_experiment_contextual-tagging_field_max_suggestions', 5 ); + $this->assertEquals( 5, $max_suggestions ); + } + + /** + * Test that strategy sanitization works correctly. + * + * @since 0.6.0 + */ + public function test_sanitize_strategy() { + $experiment = new Contextual_Tagging(); + + $this->assertEquals( 'existing_only', $experiment->sanitize_strategy( 'existing_only' ) ); + $this->assertEquals( 'allow_new', $experiment->sanitize_strategy( 'allow_new' ) ); + $this->assertEquals( 'existing_only', $experiment->sanitize_strategy( 'invalid_value' ) ); + $this->assertEquals( 'existing_only', $experiment->sanitize_strategy( '' ) ); + } + + /** + * Test that max suggestions sanitization works correctly. + * + * @since 0.6.0 + */ + public function test_sanitize_max_suggestions() { + $experiment = new Contextual_Tagging(); + + $this->assertEquals( 5, $experiment->sanitize_max_suggestions( 5 ) ); + $this->assertEquals( 1, $experiment->sanitize_max_suggestions( 0 ) ); + $this->assertEquals( 1, $experiment->sanitize_max_suggestions( -1 ) ); + $this->assertEquals( 10, $experiment->sanitize_max_suggestions( 15 ) ); + $this->assertEquals( 7, $experiment->sanitize_max_suggestions( '7' ) ); + } +} diff --git a/webpack.config.js b/webpack.config.js index bb9292578..d2d6f6511 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,6 +19,11 @@ module.exports = { 'src/admin/settings', 'index.scss' ), + 'experiments/contextual-tagging': path.resolve( + process.cwd(), + 'src/experiments/contextual-tagging', + 'index.tsx' + ), 'experiments/abilities-explorer': path.resolve( process.cwd(), 'src/experiments/abilities-explorer', From b56a454206114d3de159c94ff42099975ba77a83 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 11:47:58 -0400 Subject: [PATCH 30/72] Add styles to the Contextual Tagging editor UI elements --- .../Contextual_Tagging/Contextual_Tagging.php | 72 ++++++++++++--- .../Contextual_Tagging/Contextual_Tagging.php | 1 + src/experiments/contextual-tagging/index.scss | 90 +++++++++++++++++++ src/experiments/contextual-tagging/index.tsx | 5 ++ 4 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 src/experiments/contextual-tagging/index.scss diff --git a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php index 822eafbdf..5e1aa38c8 100644 --- a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php +++ b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php @@ -317,19 +317,17 @@ static function ( $key, $value ) { // Get the taxonomy label for the prompt. $taxonomy_label = $this->get_taxonomy_label( $taxonomy ); + // Build the system instruction directly to avoid esc_html() escaping JSON syntax. + $system_instruction = $this->build_system_instruction( + $taxonomy_label, + $max_suggestions, + $strategy_instruction, + $existing_terms_instruction + ); + // Generate the suggestions using the AI client. $result = wp_ai_client_prompt( '"""' . $context . '"""' ) - ->using_system_instruction( - $this->get_system_instruction( - null, - array( - 'strategy' => $strategy_instruction, - 'max_suggestions' => $max_suggestions, - 'taxonomy' => $taxonomy_label, - 'existing_terms' => $existing_terms_instruction, - ) - ) - ) + ->using_system_instruction( $system_instruction ) ->using_temperature( 0.5 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) ->generate_text(); @@ -424,6 +422,58 @@ protected function get_taxonomy_label( string $taxonomy ): string { return $taxonomy; } + /** + * Builds the system instruction for the AI prompt. + * + * Built directly rather than loaded from a file to avoid esc_html() + * escaping JSON syntax characters in the instruction. + * + * @since 0.6.0 + * + * @param string $taxonomy The taxonomy label. + * @param int $max_suggestions The maximum number of suggestions. + * @param string $strategy The strategy instruction text. + * @param string $existing_terms The existing terms instruction text. + * @return string The system instruction. + */ + protected function build_system_instruction( + string $taxonomy, + int $max_suggestions, + string $strategy, + string $existing_terms + ): string { + return implode( + "\n", + array( + "You are a content taxonomy assistant for a WordPress website. Your task is to analyze article content and suggest relevant {$taxonomy} terms.", + '', + "Goal: Analyze the provided content (title, body, and any existing context) and suggest up to {$max_suggestions} relevant terms for the {$taxonomy} taxonomy.", + '', + 'Output format:', + 'Return ONLY a valid JSON array. No prose, no markdown, no code fences.', + 'Each element is an object with these keys:', + ' "term" - a string with the suggested term name (1-3 words, lowercase)', + ' "confidence" - a number between 0 and 1', + ' "is_new" - a boolean indicating if this term does not already exist on the site', + ' "parent" - (optional, categories only) string name of the parent category', + '', + 'Example output for an article about machine learning in healthcare:', + '[{"term": "machine learning", "confidence": 0.95, "is_new": true}, {"term": "healthcare", "confidence": 0.9, "is_new": false}]', + '', + 'Rules:', + '- The "term" field must contain ONLY the human-readable tag or category name.', + '- Confidence should reflect relevance: 1.0 = perfect match, 0.5 = somewhat relevant.', + '- Do not suggest duplicate or near-duplicate terms.', + '- Prioritize specificity and relevance over breadth.', + '- Sort suggestions by confidence, highest first.', + $strategy, + $existing_terms, + '', + 'The content you will be provided is delimited by triple quotes.', + ) + ); + } + /** * Parses the AI response into structured suggestions. * diff --git a/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php b/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php index c44db9374..01fec18cc 100644 --- a/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php +++ b/includes/Experiments/Contextual_Tagging/Contextual_Tagging.php @@ -121,6 +121,7 @@ public function enqueue_assets( string $hook_suffix ): void { } Asset_Loader::enqueue_script( 'contextual_tagging', 'experiments/contextual-tagging' ); + Asset_Loader::enqueue_style( 'contextual_tagging', 'experiments/contextual-tagging' ); Asset_Loader::localize_script( 'contextual_tagging', 'ContextualTaggingData', diff --git a/src/experiments/contextual-tagging/index.scss b/src/experiments/contextual-tagging/index.scss new file mode 100644 index 000000000..003424f0f --- /dev/null +++ b/src/experiments/contextual-tagging/index.scss @@ -0,0 +1,90 @@ +.ai-contextual-tagging { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + + &__generate-button { + width: 100%; + justify-content: center; + } + + &__hint { + color: #757575; + font-size: 12px; + font-style: italic; + margin-top: 4px; + } + + &__loading { + display: flex; + justify-content: center; + padding: 8px 0; + } + + &__pills { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + + &__pill { + display: inline-flex; + align-items: center; + border: 1px solid #c3c4c7; + border-radius: 16px; + background: #f0f0f1; + overflow: hidden; + + &--new { + border-style: dashed; + background: #f0f6fc; + border-color: #72aee6; + } + } + + &__pill-accept { + padding: 2px 4px 2px 10px !important; + min-height: 28px !important; + font-size: 12px !important; + border: none !important; + background: transparent !important; + cursor: pointer; + + &:hover { + color: var(--wp-admin-theme-color, #3858e9); + } + } + + &__pill-badge { + display: inline-block; + margin-left: 4px; + padding: 1px 5px; + border-radius: 8px; + background: #72aee6; + color: #fff; + font-size: 10px; + line-height: 1.4; + vertical-align: middle; + } + + &__pill-dismiss { + min-height: 28px !important; + min-width: 24px !important; + padding: 0 4px !important; + border: none !important; + background: transparent !important; + border-left: 1px solid #c3c4c7 !important; + border-radius: 0 !important; + + &:hover { + color: #d63638; + } + } + + &__actions { + display: flex; + gap: 12px; + font-size: 12px; + } +} diff --git a/src/experiments/contextual-tagging/index.tsx b/src/experiments/contextual-tagging/index.tsx index ad7c300f6..e7da500ea 100644 --- a/src/experiments/contextual-tagging/index.tsx +++ b/src/experiments/contextual-tagging/index.tsx @@ -2,6 +2,11 @@ * Contextual tagging experiment plugin registration. */ +/** + * Styles + */ +import './index.scss'; + /** * WordPress dependencies */ From 32855b5c24d200fef2b11b8b039b8bd6e7e850ee Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 12:11:29 -0400 Subject: [PATCH 31/72] Update the button injection into the term panels. Ensure the suggestion buttons are attached to the panel visibility. --- .../components/CategoryPanelWrapper.tsx | 93 +++----------- .../components/TagPanelWrapper.tsx | 91 +++----------- .../components/usePanelInjection.ts | 116 ++++++++++++++++++ 3 files changed, 150 insertions(+), 150 deletions(-) create mode 100644 src/experiments/contextual-tagging/components/usePanelInjection.ts diff --git a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx b/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx index 2fb17460e..a97ab2340 100644 --- a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx +++ b/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx @@ -5,41 +5,20 @@ /** * WordPress dependencies */ -import { useEffect, useState, createRoot } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; /** * Internal dependencies */ import SuggestionPanel from './SuggestionPanel'; +import { usePanelInjection } from './usePanelInjection'; /** * Container ID for the category suggestions. */ const CONTAINER_ID = 'ai-contextual-tagging-categories'; -/** - * Finds a sidebar panel by its toggle button text. - * - * @param title The panel title text to search for. - * @return The panel body element, or null if not found. - */ -function findPanelByTitle( title: string ): HTMLElement | null { - const panelBodies = document.querySelectorAll( - '.components-panel__body' - ); - - for ( const panel of panelBodies ) { - const toggle = panel.querySelector( - '.components-panel__body-toggle' - ); - if ( toggle?.textContent?.trim() === title ) { - return panel as HTMLElement; - } - } - - return null; -} - /** * CategoryPanelWrapper component. * @@ -49,68 +28,32 @@ function findPanelByTitle( title: string ): HTMLElement | null { * @return null - Renders via portal. */ export default function CategoryPanelWrapper(): null { - const [ container, setContainer ] = useState< HTMLElement | null >( + const container = usePanelInjection( 'Categories', CONTAINER_ID ); + const rootRef = useRef< ReturnType< typeof createRoot > | null >( null ); useEffect( () => { - const findAndAttach = (): boolean => { - // Don't create duplicate containers. - if ( document.getElementById( CONTAINER_ID ) ) { - return true; - } - - // Find the Categories panel by its toggle button text. - const categoriesPanel = findPanelByTitle( 'Categories' ); - - if ( ! categoriesPanel ) { - return false; + if ( ! container ) { + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; } - - // Create and inject our container at the end of the panel. - const el = document.createElement( 'div' ); - el.id = CONTAINER_ID; - categoriesPanel.appendChild( el ); - setContainer( el ); - return true; - }; - - // Try immediately. - if ( findAndAttach() ) { return; } - // Observe for the panel appearing. - const observer = new MutationObserver( () => { - if ( findAndAttach() ) { - observer.disconnect(); - } - } ); - - observer.observe( document.body, { - childList: true, - subtree: true, - } ); - - return () => { - observer.disconnect(); - const el = document.getElementById( CONTAINER_ID ); - if ( el ) { - el.remove(); - } - }; - }, [] ); - - useEffect( () => { - if ( ! container ) { - return; + if ( ! rootRef.current ) { + rootRef.current = createRoot( container ); } - - const root = createRoot( container ); - root.render( ); + rootRef.current.render( + + ); return () => { - root.unmount(); + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; + } }; }, [ container ] ); diff --git a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx index 4a0518ace..53af1d907 100644 --- a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx +++ b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx @@ -5,41 +5,20 @@ /** * WordPress dependencies */ -import { useEffect, useState, createRoot } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; /** * Internal dependencies */ import SuggestionPanel from './SuggestionPanel'; +import { usePanelInjection } from './usePanelInjection'; /** * Container ID for the tag suggestions. */ const CONTAINER_ID = 'ai-contextual-tagging-tags'; -/** - * Finds a sidebar panel by its toggle button text. - * - * @param title The panel title text to search for. - * @return The panel body element, or null if not found. - */ -function findPanelByTitle( title: string ): HTMLElement | null { - const panelBodies = document.querySelectorAll( - '.components-panel__body' - ); - - for ( const panel of panelBodies ) { - const toggle = panel.querySelector( - '.components-panel__body-toggle' - ); - if ( toggle?.textContent?.trim() === title ) { - return panel as HTMLElement; - } - } - - return null; -} - /** * TagPanelWrapper component. * @@ -49,68 +28,30 @@ function findPanelByTitle( title: string ): HTMLElement | null { * @return null - Renders via portal. */ export default function TagPanelWrapper(): null { - const [ container, setContainer ] = useState< HTMLElement | null >( + const container = usePanelInjection( 'Tags', CONTAINER_ID ); + const rootRef = useRef< ReturnType< typeof createRoot > | null >( null ); useEffect( () => { - const findAndAttach = (): boolean => { - // Don't create duplicate containers. - if ( document.getElementById( CONTAINER_ID ) ) { - return true; - } - - // Find the Tags panel by its toggle button text. - const tagsPanel = findPanelByTitle( 'Tags' ); - - if ( ! tagsPanel ) { - return false; + if ( ! container ) { + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; } - - // Create and inject our container at the end of the panel. - const el = document.createElement( 'div' ); - el.id = CONTAINER_ID; - tagsPanel.appendChild( el ); - setContainer( el ); - return true; - }; - - // Try immediately. - if ( findAndAttach() ) { return; } - // Observe for the panel appearing. - const observer = new MutationObserver( () => { - if ( findAndAttach() ) { - observer.disconnect(); - } - } ); - - observer.observe( document.body, { - childList: true, - subtree: true, - } ); - - return () => { - observer.disconnect(); - const el = document.getElementById( CONTAINER_ID ); - if ( el ) { - el.remove(); - } - }; - }, [] ); - - useEffect( () => { - if ( ! container ) { - return; + if ( ! rootRef.current ) { + rootRef.current = createRoot( container ); } - - const root = createRoot( container ); - root.render( ); + rootRef.current.render( ); return () => { - root.unmount(); + if ( rootRef.current ) { + rootRef.current.unmount(); + rootRef.current = null; + } }; }, [ container ] ); diff --git a/src/experiments/contextual-tagging/components/usePanelInjection.ts b/src/experiments/contextual-tagging/components/usePanelInjection.ts new file mode 100644 index 000000000..1b9c0f503 --- /dev/null +++ b/src/experiments/contextual-tagging/components/usePanelInjection.ts @@ -0,0 +1,116 @@ +/** + * Shared hook for injecting a container into an editor sidebar panel. + * + * Handles panel toggle cycles by continuously observing the DOM + * and re-injecting the container when the panel is reopened. + */ + +/** + * WordPress dependencies + */ +import { useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Finds a sidebar panel by its toggle button text. + * + * @param title The panel title text to search for. + * @return The panel body element, or null if not found. + */ +function findPanelByTitle( title: string ): HTMLElement | null { + const panelBodies = document.querySelectorAll( + '.components-panel__body' + ); + + for ( const panel of panelBodies ) { + const toggle = panel.querySelector( + '.components-panel__body-toggle' + ); + if ( toggle?.textContent?.trim() === title ) { + return panel as HTMLElement; + } + } + + return null; +} + +/** + * Hook that injects and maintains a container element at the end of a + * named editor sidebar panel. Handles panel toggle (close/reopen) by + * continuously observing the DOM and re-injecting as needed. + * + * @param panelTitle The panel toggle button text (e.g., "Tags"). + * @param containerId A unique ID for the injected container element. + * @return The container element, or null if not yet injected. + */ +export function usePanelInjection( + panelTitle: string, + containerId: string +): HTMLElement | null { + const [ container, setContainer ] = useState< HTMLElement | null >( + null + ); + const containerRef = useRef< HTMLElement | null >( null ); + + useEffect( () => { + const findAndAttach = (): void => { + const panel = findPanelByTitle( panelTitle ); + const isOpen = + panel && panel.classList.contains( 'is-opened' ); + + // If the panel is closed or gone, remove our container. + if ( ! isOpen ) { + const existing = + document.getElementById( containerId ); + if ( existing ) { + existing.remove(); + } + if ( containerRef.current ) { + containerRef.current = null; + setContainer( null ); + } + return; + } + + // Panel is open — check if our container already exists. + const existing = document.getElementById( containerId ); + if ( existing ) { + // Ensure it's the last child (not displaced by re-renders). + if ( panel.lastElementChild !== existing ) { + panel.appendChild( existing ); + } + return; + } + + // Create and inject our container at the end of the panel. + const el = document.createElement( 'div' ); + el.id = containerId; + panel.appendChild( el ); + containerRef.current = el; + setContainer( el ); + }; + + // Try immediately. + findAndAttach(); + + // Continuously observe for panel toggles and re-renders. + const observer = new MutationObserver( () => { + findAndAttach(); + } ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + return () => { + observer.disconnect(); + const el = document.getElementById( containerId ); + if ( el ) { + el.remove(); + } + containerRef.current = null; + }; + }, [ panelTitle, containerId ] ); + + return container; +} From a6378ccd4da652ef3aeefe2b8f0bafa32c8077c8 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 12:22:33 -0400 Subject: [PATCH 32/72] Minor styling update to editor UI --- .../contextual-tagging/components/SuggestionPanel.tsx | 10 +++------- src/experiments/contextual-tagging/index.scss | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx index 963e5cd97..16b2be2ae 100644 --- a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx +++ b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx @@ -58,14 +58,12 @@ export default function SuggestionPanel( { disabled={ isGenerating || ! hasEnoughContent } isBusy={ isGenerating } className="ai-contextual-tagging__generate-button" + __next40pxDefaultSize > { isGenerating ? __( 'Generating…', 'ai' ) : /* translators: %s: Taxonomy label (e.g., "Tags" or "Categories"). */ - sprintf( - __( 'Suggest %s', 'ai' ), - taxonomyLabel - ) } + sprintf( __( 'Suggest %s', 'ai' ), taxonomyLabel ) } ) } @@ -98,9 +96,7 @@ export default function SuggestionPanel( { > ) } { ! hasEnoughContent && ! hasSuggestions && ( -

+

{ __( 'Add more content to enable AI suggestions (approximately 150 words).', 'ai' @@ -85,63 +93,79 @@ export default function SuggestionPanel( { { hasSuggestions && (

- { suggestions.map( ( suggestion: TagSuggestion ) => ( - - -
-
- - + +
+ + + + + + + +
) }
diff --git a/src/experiments/contextual-tagging/components/usePanelInjection.ts b/src/experiments/contextual-tagging/components/usePanelInjection.ts index 1b9c0f503..d57f8049b 100644 --- a/src/experiments/contextual-tagging/components/usePanelInjection.ts +++ b/src/experiments/contextual-tagging/components/usePanelInjection.ts @@ -52,7 +52,13 @@ export function usePanelInjection( const containerRef = useRef< HTMLElement | null >( null ); useEffect( () => { + let mutating = false; + const findAndAttach = (): void => { + if ( mutating ) { + return; + } + const panel = findPanelByTitle( panelTitle ); const isOpen = panel && panel.classList.contains( 'is-opened' ); @@ -62,7 +68,9 @@ export function usePanelInjection( const existing = document.getElementById( containerId ); if ( existing ) { + mutating = true; existing.remove(); + mutating = false; } if ( containerRef.current ) { containerRef.current = null; @@ -75,8 +83,13 @@ export function usePanelInjection( const existing = document.getElementById( containerId ); if ( existing ) { // Ensure it's the last child (not displaced by re-renders). - if ( panel.lastElementChild !== existing ) { + if ( + panel && + panel.lastElementChild !== existing + ) { + mutating = true; panel.appendChild( existing ); + mutating = false; } return; } @@ -84,7 +97,9 @@ export function usePanelInjection( // Create and inject our container at the end of the panel. const el = document.createElement( 'div' ); el.id = containerId; - panel.appendChild( el ); + mutating = true; + panel!.appendChild( el ); + mutating = false; containerRef.current = el; setContainer( el ); }; diff --git a/src/experiments/contextual-tagging/index.scss b/src/experiments/contextual-tagging/index.scss index 87633a45e..13a685ddc 100644 --- a/src/experiments/contextual-tagging/index.scss +++ b/src/experiments/contextual-tagging/index.scss @@ -1,16 +1,13 @@ .ai-contextual-tagging { margin-top: 12px; padding-top: 12px; - border-top: 1px solid #e0e0e0; + border-top: 1px solid var(--wp-admin-border-color, #c3c4c7); &__generate-button { width: 100%; } &__hint { - color: #757575; - font-size: 12px; - font-style: italic; margin-top: 4px; } @@ -23,23 +20,17 @@ &__pills { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; margin-bottom: 8px; } &__pill { display: inline-flex; align-items: center; - border: 1px solid #c3c4c7; - border-radius: 16px; - background: #f0f0f1; + border: 1px solid var(--wp-admin-theme-color, #007cba); + border-radius: 2px; + background: rgba(var(--wp-admin-theme-color--rgb, 0, 124, 186), 0.04); overflow: hidden; - - &--new { - border-style: dashed; - background: #f0f6fc; - border-color: #72aee6; - } } &__pill-accept { @@ -51,7 +42,7 @@ cursor: pointer; &:hover { - color: var(--wp-admin-theme-color, #3858e9); + color: var(--wp-admin-theme-color, #007cba); } } @@ -59,8 +50,8 @@ display: inline-block; margin-left: 4px; padding: 1px 5px; - border-radius: 8px; - background: #72aee6; + border-radius: 2px; + background: var(--wp-admin-theme-color, #007cba); color: #fff; font-size: 10px; line-height: 1.4; @@ -73,17 +64,15 @@ padding: 0 4px !important; border: none !important; background: transparent !important; - border-left: 1px solid #c3c4c7 !important; + border-left: 1px solid var(--wp-admin-theme-color, #007cba) !important; border-radius: 0 !important; &:hover { - color: #d63638; + color: #cc1818; } } &__actions { - display: flex; - gap: 12px; font-size: 12px; } } From 934ed5dad611e3544f3bc38c7c8a0fd5042f76a4 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 13:07:25 -0400 Subject: [PATCH 34/72] Use the editor.PostTaxonomyType filter to inject the contextual tagging components --- .../components/CategoryPanelWrapper.tsx | 61 -------- .../components/TagPanelWrapper.tsx | 59 -------- .../components/usePanelInjection.ts | 131 ------------------ src/experiments/contextual-tagging/index.tsx | 48 +++++-- 4 files changed, 35 insertions(+), 264 deletions(-) delete mode 100644 src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx delete mode 100644 src/experiments/contextual-tagging/components/TagPanelWrapper.tsx delete mode 100644 src/experiments/contextual-tagging/components/usePanelInjection.ts diff --git a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx b/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx deleted file mode 100644 index a97ab2340..000000000 --- a/src/experiments/contextual-tagging/components/CategoryPanelWrapper.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Wrapper component that injects AI suggestions into the Categories sidebar panel. - */ - -/** - * WordPress dependencies - */ -import { useEffect, useRef } from '@wordpress/element'; -import { createRoot } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SuggestionPanel from './SuggestionPanel'; -import { usePanelInjection } from './usePanelInjection'; - -/** - * Container ID for the category suggestions. - */ -const CONTAINER_ID = 'ai-contextual-tagging-categories'; - -/** - * CategoryPanelWrapper component. - * - * Uses DOM observation to inject the suggestion panel into the Categories - * sidebar panel in the block editor. - * - * @return null - Renders via portal. - */ -export default function CategoryPanelWrapper(): null { - const container = usePanelInjection( 'Categories', CONTAINER_ID ); - const rootRef = useRef< ReturnType< typeof createRoot > | null >( - null - ); - - useEffect( () => { - if ( ! container ) { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - return; - } - - if ( ! rootRef.current ) { - rootRef.current = createRoot( container ); - } - rootRef.current.render( - - ); - - return () => { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - }; - }, [ container ] ); - - return null; -} diff --git a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx b/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx deleted file mode 100644 index 53af1d907..000000000 --- a/src/experiments/contextual-tagging/components/TagPanelWrapper.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Wrapper component that injects AI suggestions into the Tags sidebar panel. - */ - -/** - * WordPress dependencies - */ -import { useEffect, useRef } from '@wordpress/element'; -import { createRoot } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SuggestionPanel from './SuggestionPanel'; -import { usePanelInjection } from './usePanelInjection'; - -/** - * Container ID for the tag suggestions. - */ -const CONTAINER_ID = 'ai-contextual-tagging-tags'; - -/** - * TagPanelWrapper component. - * - * Uses DOM observation to inject the suggestion panel into the Tags - * sidebar panel in the block editor. - * - * @return null - Renders via portal. - */ -export default function TagPanelWrapper(): null { - const container = usePanelInjection( 'Tags', CONTAINER_ID ); - const rootRef = useRef< ReturnType< typeof createRoot > | null >( - null - ); - - useEffect( () => { - if ( ! container ) { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - return; - } - - if ( ! rootRef.current ) { - rootRef.current = createRoot( container ); - } - rootRef.current.render( ); - - return () => { - if ( rootRef.current ) { - rootRef.current.unmount(); - rootRef.current = null; - } - }; - }, [ container ] ); - - return null; -} diff --git a/src/experiments/contextual-tagging/components/usePanelInjection.ts b/src/experiments/contextual-tagging/components/usePanelInjection.ts deleted file mode 100644 index d57f8049b..000000000 --- a/src/experiments/contextual-tagging/components/usePanelInjection.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Shared hook for injecting a container into an editor sidebar panel. - * - * Handles panel toggle cycles by continuously observing the DOM - * and re-injecting the container when the panel is reopened. - */ - -/** - * WordPress dependencies - */ -import { useEffect, useRef, useState } from '@wordpress/element'; - -/** - * Finds a sidebar panel by its toggle button text. - * - * @param title The panel title text to search for. - * @return The panel body element, or null if not found. - */ -function findPanelByTitle( title: string ): HTMLElement | null { - const panelBodies = document.querySelectorAll( - '.components-panel__body' - ); - - for ( const panel of panelBodies ) { - const toggle = panel.querySelector( - '.components-panel__body-toggle' - ); - if ( toggle?.textContent?.trim() === title ) { - return panel as HTMLElement; - } - } - - return null; -} - -/** - * Hook that injects and maintains a container element at the end of a - * named editor sidebar panel. Handles panel toggle (close/reopen) by - * continuously observing the DOM and re-injecting as needed. - * - * @param panelTitle The panel toggle button text (e.g., "Tags"). - * @param containerId A unique ID for the injected container element. - * @return The container element, or null if not yet injected. - */ -export function usePanelInjection( - panelTitle: string, - containerId: string -): HTMLElement | null { - const [ container, setContainer ] = useState< HTMLElement | null >( - null - ); - const containerRef = useRef< HTMLElement | null >( null ); - - useEffect( () => { - let mutating = false; - - const findAndAttach = (): void => { - if ( mutating ) { - return; - } - - const panel = findPanelByTitle( panelTitle ); - const isOpen = - panel && panel.classList.contains( 'is-opened' ); - - // If the panel is closed or gone, remove our container. - if ( ! isOpen ) { - const existing = - document.getElementById( containerId ); - if ( existing ) { - mutating = true; - existing.remove(); - mutating = false; - } - if ( containerRef.current ) { - containerRef.current = null; - setContainer( null ); - } - return; - } - - // Panel is open — check if our container already exists. - const existing = document.getElementById( containerId ); - if ( existing ) { - // Ensure it's the last child (not displaced by re-renders). - if ( - panel && - panel.lastElementChild !== existing - ) { - mutating = true; - panel.appendChild( existing ); - mutating = false; - } - return; - } - - // Create and inject our container at the end of the panel. - const el = document.createElement( 'div' ); - el.id = containerId; - mutating = true; - panel!.appendChild( el ); - mutating = false; - containerRef.current = el; - setContainer( el ); - }; - - // Try immediately. - findAndAttach(); - - // Continuously observe for panel toggles and re-renders. - const observer = new MutationObserver( () => { - findAndAttach(); - } ); - - observer.observe( document.body, { - childList: true, - subtree: true, - } ); - - return () => { - observer.disconnect(); - const el = document.getElementById( containerId ); - if ( el ) { - el.remove(); - } - containerRef.current = null; - }; - }, [ panelTitle, containerId ] ); - - return container; -} diff --git a/src/experiments/contextual-tagging/index.tsx b/src/experiments/contextual-tagging/index.tsx index e7da500ea..1156251a5 100644 --- a/src/experiments/contextual-tagging/index.tsx +++ b/src/experiments/contextual-tagging/index.tsx @@ -10,20 +10,42 @@ import './index.scss'; /** * WordPress dependencies */ -import { registerPlugin } from '@wordpress/plugins'; +import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies */ -import TagPanelWrapper from './components/TagPanelWrapper'; -import CategoryPanelWrapper from './components/CategoryPanelWrapper'; - -// Register plugin for tag suggestions in the Tags sidebar panel. -registerPlugin( 'ai-contextual-tagging-tags', { - render: TagPanelWrapper, -} ); - -// Register plugin for category suggestions in the Categories sidebar panel. -registerPlugin( 'ai-contextual-tagging-categories', { - render: CategoryPanelWrapper, -} ); +import SuggestionPanel from './components/SuggestionPanel'; + +const SUPPORTED_TAXONOMIES = [ 'post_tag', 'category' ]; + +/** + * Wraps the taxonomy selector component with the AI suggestion panel. + * + * @param OriginalComponent The original taxonomy selector component. + * @return The wrapped component. + */ +function withContextualTagging( + OriginalComponent: React.ComponentType< any > +) { + return function ContextualTaggingWrapper( props: any ) { + const { slug } = props; + + if ( ! SUPPORTED_TAXONOMIES.includes( slug ) ) { + return ; + } + + return ( + <> + + + + ); + }; +} + +addFilter( + 'editor.PostTaxonomyType', + 'ai/contextual-tagging', + withContextualTagging +); From 5eb0919e4707bc1608871fa7372c9337fa2b0b0d Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 13:11:58 -0400 Subject: [PATCH 35/72] use core wordcount package to count the post content words. --- .../contextual-tagging/components/useContextualTagging.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/experiments/contextual-tagging/components/useContextualTagging.ts b/src/experiments/contextual-tagging/components/useContextualTagging.ts index f39fa3632..e1b161f9e 100644 --- a/src/experiments/contextual-tagging/components/useContextualTagging.ts +++ b/src/experiments/contextual-tagging/components/useContextualTagging.ts @@ -11,6 +11,7 @@ import { store as editorStore } from '@wordpress/editor'; import { useState, useCallback } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import apiFetch from '@wordpress/api-fetch'; +import { count as wordCount } from '@wordpress/wordcount'; /** * Internal dependencies @@ -91,11 +92,8 @@ export function useContextualTagging( taxonomy: string ): { const [ suggestions, setSuggestions ] = useState< TagSuggestion[] >( [] ); // Check if content has enough words. - const wordCount = content - ? content.replace( /<[^>]*>/g, '' ).split( /\s+/ ).filter( Boolean ) - .length - : 0; - const hasEnoughContent = wordCount >= MINIMUM_WORD_COUNT; + const hasEnoughContent = + wordCount( content || '', 'words' ) >= MINIMUM_WORD_COUNT; const handleGenerate = useCallback( async () => { const settings = getSettings(); From 3d6761bf35bf62f73357897aac04efd7a0ffc3d9 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 17 Mar 2026 13:48:29 -0400 Subject: [PATCH 36/72] Use taxonomy object to retrieve labels. --- .../components/SuggestionPanel.tsx | 107 ++++++++---------- .../components/useContextualTagging.ts | 92 +++++++-------- .../block-editor-augmentation.d.ts | 8 +- 3 files changed, 89 insertions(+), 118 deletions(-) diff --git a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx index 5a1d55a20..5fe705167 100644 --- a/src/experiments/contextual-tagging/components/SuggestionPanel.tsx +++ b/src/experiments/contextual-tagging/components/SuggestionPanel.tsx @@ -5,12 +5,9 @@ /** * WordPress dependencies */ -import { - Button, - Flex, - FlexItem, - Spinner, -} from '@wordpress/components'; +import { Button, Flex, FlexItem, Spinner } from '@wordpress/components'; +import { select } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; import { __, sprintf } from '@wordpress/i18n'; import { close as closeIcon, update } from '@wordpress/icons'; @@ -46,10 +43,8 @@ export default function SuggestionPanel( { handleDismissAll, } = useContextualTagging( taxonomy ); - const taxonomyLabel = - taxonomy === 'category' - ? __( 'Categories', 'ai' ) - : __( 'Tags', 'ai' ); + const taxonomyObject: any = select( coreStore ).getTaxonomy( taxonomy ); + const taxonomyLabel: string = taxonomyObject?.name ?? taxonomy; const hasSuggestions = suggestions.length > 0; @@ -93,60 +88,48 @@ export default function SuggestionPanel( { { hasSuggestions && (
- { suggestions.map( - ( suggestion: TagSuggestion ) => ( - ( + + - +
- + From f129b56f9f1453877b850538470b7fa68fa267cf Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Fri, 20 Mar 2026 09:52:24 -0400 Subject: [PATCH 57/72] Remove existing terms from the prompt and only pass currently assigned terms. Update system instructions and unit tests accordingly. --- .../Contextual_Tagging/Contextual_Tagging.php | 91 +++++++------- .../Contextual_Tagging/system-instruction.php | 18 ++- .../Abilities/Contextual_TaggingTest.php | 116 ++++++++++++++---- 3 files changed, 142 insertions(+), 83 deletions(-) diff --git a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php index 2dc8c1745..d0e6e9de5 100644 --- a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php +++ b/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php @@ -273,6 +273,10 @@ protected function meta(): array { /** * Generates taxonomy term suggestions from the given content. * + * The LLM generates suggestions based purely on content analysis + * and the currently assigned terms. Post-processing then matches + * suggestions against existing terms and applies the strategy. + * * @since x.x.x * * @param string|array $context The context to generate suggestions from. @@ -301,40 +305,28 @@ static function ( $key, $value ) { ); } - // Fetch existing terms for the taxonomy. - $existing_terms = $this->get_existing_terms( $taxonomy ); - - // Get the taxonomy label for the prompt. - $taxonomy_label = $this->get_taxonomy_label( $taxonomy ); - - // Build the prompt with XML-like content wrapping. - $prompt = $this->build_prompt( $context, $taxonomy, $strategy, $existing_terms, $assigned_terms ); + // Build the prompt. + // We supply the currently assigned terms to avoid redundant suggestions. + $prompt = $this->build_prompt( $context, $taxonomy, $assigned_terms ); /** - * Filters the content string before it is sent to the AI model for taxonomy suggestion generation. + * Filters the prompt string before it is sent to the AI model for taxonomy suggestion generation. * - * Allows developers to modify, augment, or replace the content that the AI analyzes - * when generating tag and category suggestions. + * Allows developers to modify, augment, or replace the prompt that the AI analyzes + * when generating taxonomy term suggestions. * * @since x.x.x * - * @param string $prompt The prompt string to be sent to the AI model. - * @param string $taxonomy The taxonomy slug being suggested for (e.g., 'post_tag', 'category'). - * @param string $strategy The suggestion strategy ('existing_only' or 'allow_new'). + * @param string $prompt The prompt string to be sent to the AI model. + * @param string|array $context The context to generate suggestions from. + * @param string $taxonomy The taxonomy slug being suggested for (e.g., 'post_tag', 'category'). + * @param array $assigned_terms Terms already assigned to the post. */ - $prompt = (string) apply_filters( 'wpai_contextual_tagging_content', $prompt, $taxonomy, $strategy ); + $prompt = (string) apply_filters( 'wpai_contextual_tagging_prompt', $prompt, $context, $taxonomy, $assigned_terms ); // Generate the suggestions using the AI client with structured output. $result = wp_ai_client_prompt( $prompt ) - ->using_system_instruction( - $this->get_system_instruction( - 'system-instruction.php', - array( - 'taxonomy' => $taxonomy_label, - 'max_suggestions' => $max_suggestions, - ) - ) - ) + ->using_system_instruction( $this->get_system_instruction() ) ->using_temperature( 0.5 ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) ->as_json_response( $this->suggestions_schema() ) @@ -344,8 +336,11 @@ static function ( $key, $value ) { return $result; } - // Parse the structured JSON response. - $suggestions = $this->parse_suggestions( $result, $existing_terms, $max_suggestions ); + // Fetch existing terms for post-processing (matching, not for the prompt). + $existing_terms = $this->get_existing_terms( $taxonomy ); + + // Parse, match against existing terms, filter, and limit. + $suggestions = $this->parse_suggestions( $result, $existing_terms, $strategy, $assigned_terms, $max_suggestions ); if ( is_wp_error( $suggestions ) ) { return $suggestions; @@ -421,28 +416,15 @@ protected function get_taxonomy_label( string $taxonomy ): string { * * @param string $context The content to analyze. * @param string $taxonomy The taxonomy slug. - * @param string $strategy The suggestion strategy. - * @param array $existing_terms The existing terms. * @param array $assigned_terms Terms already assigned to the post. * @return string The formatted prompt. */ - protected function build_prompt( string $context, string $taxonomy, string $strategy, array $existing_terms, array $assigned_terms = array() ): string { + protected function build_prompt( string $context, string $taxonomy, array $assigned_terms = array() ): string { $prompt_parts = array(); + $prompt_parts[] = '' . $taxonomy . ''; $prompt_parts[] = '' . $context . ''; - if ( Contextual_Tagging_Experiment::STRATEGY_EXISTING_ONLY === $strategy ) { - $prompt_parts[] = 'Only suggest terms that already exist on the site. Set "is_new" to false for all suggestions. Do not invent new terms.'; - } else { - $prompt_parts[] = 'You may suggest new terms if no good existing match exists. Set "is_new" to true for new terms and false for existing terms. Prefer existing terms when possible.'; - } - - if ( ! empty( $existing_terms ) ) { - $prompt_parts[] = '' . implode( ', ', $existing_terms ) . ''; - } elseif ( Contextual_Tagging_Experiment::STRATEGY_EXISTING_ONLY === $strategy ) { - $prompt_parts[] = 'No existing terms are available. Return an empty suggestions array.'; - } - if ( ! empty( $assigned_terms ) ) { $prompt_parts[] = '' . implode( ', ', $assigned_terms ) . ''; } @@ -468,10 +450,9 @@ protected function suggestions_schema(): array { 'properties' => array( 'term' => array( 'type' => 'string' ), 'confidence' => array( 'type' => 'number' ), - 'is_new' => array( 'type' => 'boolean' ), 'parent' => array( 'type' => 'string' ), ), - 'required' => array( 'term', 'confidence', 'is_new' ), + 'required' => array( 'term', 'confidence' ), ), ), ), @@ -482,14 +463,20 @@ protected function suggestions_schema(): array { /** * Parses the AI response into structured suggestions. * + * Matches LLM suggestions against existing terms (case-insensitive), + * filters out assigned terms, applies the strategy, sorts by confidence, + * and limits to the requested number of suggestions. + * * @since x.x.x * * @param string $response The raw AI response. * @param array $existing_terms List of existing term names. - * @param int $max_suggestions The maximum number of suggestions. + * @param string $strategy The suggestion strategy ('existing_only' or 'allow_new'). + * @param array $assigned_terms Terms already assigned to the post. + * @param int $max_suggestions The maximum number of suggestions to return. * @return array|\WP_Error Parsed suggestions or error. */ - protected function parse_suggestions( string $response, array $existing_terms, int $max_suggestions ) { + protected function parse_suggestions( string $response, array $existing_terms, string $strategy, array $assigned_terms, int $max_suggestions ) { $decoded = json_decode( $response, true ); if ( ! is_array( $decoded ) || ! isset( $decoded['suggestions'] ) || ! is_array( $decoded['suggestions'] ) ) { @@ -505,6 +492,9 @@ protected function parse_suggestions( string $response, array $existing_terms, i $existing_terms_map[ strtolower( $existing_term ) ] = $existing_term; } + // Build a lowercase set of assigned terms for filtering. + $assigned_terms_lower = array_map( 'strtolower', $assigned_terms ); + $suggestions = array(); foreach ( $decoded['suggestions'] as $item ) { @@ -517,6 +507,17 @@ protected function parse_suggestions( string $response, array $existing_terms, i $is_new = ! isset( $existing_terms_map[ $term_lower ] ); $confidence = isset( $item['confidence'] ) ? (float) $item['confidence'] : 0.5; + // Skip terms already assigned to the post. + // The agent should avoid suggesting these, but just in case we'll check here as well. + if ( in_array( $term_lower, $assigned_terms_lower, true ) ) { + continue; + } + + // For existing_only strategy, skip terms that don't exist. + if ( Contextual_Tagging_Experiment::STRATEGY_EXISTING_ONLY === $strategy && $is_new ) { + continue; + } + // Use the original capitalized name for existing terms. if ( ! $is_new ) { $term = $existing_terms_map[ $term_lower ]; diff --git a/includes/Abilities/Contextual_Tagging/system-instruction.php b/includes/Abilities/Contextual_Tagging/system-instruction.php index e34e5f97c..ed38d432a 100644 --- a/includes/Abilities/Contextual_Tagging/system-instruction.php +++ b/includes/Abilities/Contextual_Tagging/system-instruction.php @@ -10,21 +10,17 @@ exit; } -// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound - -// These variables are extracted from the $data array passed to get_system_instruction(). -$taxonomy_name = $taxonomy ?? 'tags'; -$max_suggestions = $max_suggestions ?? apply_filters( 'wpai_contextual_tagging_max_suggestions', 5 ); - // phpcs:ignore Squiz.PHP.Heredoc.NotAllowed, PluginCheck.CodeAnalysis.Heredoc.NotAllowed -return << tags, with any additional context in , , and tags) and suggest up to {$max_suggestions} relevant terms for the {$taxonomy_name} taxonomy. +Goal: Analyze the provided content (wrapped in tags, with any already-applied terms in ) and suggest relevant terms for the taxonomy (wrapped in tag). Rules: -- The "term" field must contain ONLY the human-readable tag or category name (1-3 words, lowercase). -- Confidence should reflect relevance: 1.0 = perfect match, 0.5 = somewhat relevant. +- The taxonomy to suggest terms for is wrapped in the tag. +- Suggest as many relevant terms as you can identify from the content. +- The "term" field must contain ONLY the human-readable tag or category name (1-3 words). +- Confidence should reflect relevance: 1.0 = perfect match, 0.5 = somewhat relevant. Only suggest terms with confidence >= 0.5. - Do not suggest duplicate or near-duplicate terms. - Do not suggest terms listed in — they are already applied to this post. - Prioritize specificity and relevance over breadth. diff --git a/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php b/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php index c3f536975..d6f27ae10 100644 --- a/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php +++ b/tests/Integration/Includes/Abilities/Contextual_TaggingTest.php @@ -239,9 +239,9 @@ public function test_parse_suggestions_with_valid_json() { $method = $reflection->getMethod( 'parse_suggestions' ); $method->setAccessible( true ); - $response = '{"suggestions": [{"term": "development", "confidence": 0.9, "is_new": false}, {"term": "plugins", "confidence": 0.8, "is_new": true}]}'; + $response = '{"suggestions": [{"term": "development", "confidence": 0.9}, {"term": "plugins", "confidence": 0.8}]}'; - $result = $method->invoke( $this->ability, $response, array( 'development' ), 5 ); + $result = $method->invoke( $this->ability, $response, array( 'development' ), 'allow_new', array(), 5 ); $this->assertIsArray( $result, 'Result should be an array' ); $this->assertCount( 2, $result, 'Should have 2 suggestions' ); @@ -262,7 +262,7 @@ public function test_parse_suggestions_with_invalid_json() { $response = 'This is not valid JSON'; - $result = $method->invoke( $this->ability, $response, array(), 5 ); + $result = $method->invoke( $this->ability, $response, array(), 'allow_new', array(), 5 ); $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); $this->assertEquals( 'invalid_response', $result->get_error_code(), 'Error code should be invalid_response' ); @@ -286,7 +286,7 @@ public function test_parse_suggestions_limits_results() { {"term": "e", "confidence": 0.5, "is_new": false} ]}'; - $result = $method->invoke( $this->ability, $response, array(), 3 ); + $result = $method->invoke( $this->ability, $response, array(), 'allow_new', array(), 3 ); $this->assertIsArray( $result, 'Result should be an array' ); $this->assertCount( 3, $result, 'Should be limited to 3 suggestions' ); @@ -309,7 +309,7 @@ public function test_parse_suggestions_sorts_by_confidence() { {"term": "mid", "confidence": 0.6, "is_new": true} ]}'; - $result = $method->invoke( $this->ability, $response, array(), 10 ); + $result = $method->invoke( $this->ability, $response, array(), 'allow_new', array(), 10 ); $this->assertEquals( 'high', $result[0]['term'], 'First should be highest confidence' ); $this->assertEquals( 'mid', $result[1]['term'], 'Second should be mid confidence' ); @@ -331,7 +331,7 @@ public function test_parse_suggestions_clamps_confidence() { {"term": "under", "confidence": -0.5, "is_new": true} ]}'; - $result = $method->invoke( $this->ability, $response, array(), 10 ); + $result = $method->invoke( $this->ability, $response, array(), 'allow_new', array(), 10 ); $this->assertEquals( 1.0, $result[0]['confidence'], 'Confidence above 1 should be clamped to 1.0' ); $this->assertEquals( 0.0, $result[1]['confidence'], 'Confidence below 0 should be clamped to 0.0' ); @@ -352,7 +352,7 @@ public function test_parse_suggestions_preserves_parent_field() { {"term": "finance", "confidence": 0.8, "is_new": false} ]}'; - $result = $method->invoke( $this->ability, $response, array( 'finance' ), 10 ); + $result = $method->invoke( $this->ability, $response, array( 'finance' ), 'allow_new', array(), 10 ); $this->assertArrayHasKey( 'parent', $result[0], 'First suggestion should have parent key' ); $this->assertEquals( 'technology', $result[0]['parent'], 'Parent should be technology' ); @@ -377,7 +377,7 @@ public function test_parse_suggestions_skips_invalid_items() { {"term": "also valid", "confidence": 0.6, "is_new": true} ]}'; - $result = $method->invoke( $this->ability, $response, array(), 10 ); + $result = $method->invoke( $this->ability, $response, array(), 'allow_new', array(), 10 ); $this->assertCount( 2, $result, 'Should only have 2 valid suggestions' ); $this->assertEquals( 'valid', $result[0]['term'] ); @@ -396,7 +396,7 @@ public function test_parse_suggestions_defaults_missing_confidence() { $response = '{"suggestions": [{"term": "test", "is_new": true}]}'; - $result = $method->invoke( $this->ability, $response, array(), 10 ); + $result = $method->invoke( $this->ability, $response, array(), 'allow_new', array(), 10 ); $this->assertEquals( 0.5, $result[0]['confidence'], 'Missing confidence should default to 0.5' ); } @@ -414,59 +414,121 @@ public function test_parse_suggestions_overrides_is_new_from_existing_terms() { // AI says "tech" is new, but it exists in our list as "Tech". $response = '{"suggestions": [{"term": "tech", "confidence": 0.9, "is_new": true}]}'; - $result = $method->invoke( $this->ability, $response, array( 'Tech' ), 10 ); + $result = $method->invoke( $this->ability, $response, array( 'Tech' ), 'allow_new', array(), 10 ); $this->assertFalse( $result[0]['is_new'], 'Should be false because "Tech" exists (case-insensitive match)' ); $this->assertEquals( 'Tech', $result[0]['term'], 'Should use the original capitalized term name from the existing terms list' ); } /** - * Test that build_prompt() wraps content in XML tags for existing_only strategy. + * Test that build_prompt() wraps content in XML tags. * * @since x.x.x */ - public function test_build_prompt_existing_only_strategy() { + public function test_build_prompt_wraps_content() { $reflection = new \ReflectionClass( $this->ability ); $method = $reflection->getMethod( 'build_prompt' ); $method->setAccessible( true ); - $result = $method->invoke( $this->ability, 'Test content', 'post_tag', 'existing_only', array( 'php', 'javascript' ) ); + $result = $method->invoke( $this->ability, 'Test content', 'post_tag' ); $this->assertStringContainsString( 'Test content', $result, 'Should wrap content in XML tags' ); - $this->assertStringContainsString( '', $result, 'Should include strategy tag' ); - $this->assertStringContainsString( 'Only suggest terms that already exist', $result ); - $this->assertStringContainsString( 'php, javascript', $result ); } /** - * Test that build_prompt() wraps content in XML tags for allow_new strategy. + * Test that build_prompt() does not include existing terms or strategy tags. + * + * Existing terms are no longer sent to the LLM to avoid scaling issues. + * + * @since x.x.x + */ + public function test_build_prompt_excludes_existing_terms_and_strategy() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'build_prompt' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, 'Test content', 'post_tag' ); + + $this->assertStringNotContainsString( '', $result, 'Should not include existing terms' ); + $this->assertStringNotContainsString( '', $result, 'Should not include strategy tag' ); + } + + /** + * Test that build_prompt() includes assigned terms when provided. * * @since x.x.x */ - public function test_build_prompt_allow_new_strategy() { + public function test_build_prompt_includes_assigned_terms() { $reflection = new \ReflectionClass( $this->ability ); $method = $reflection->getMethod( 'build_prompt' ); $method->setAccessible( true ); - $result = $method->invoke( $this->ability, 'Test content', 'post_tag', 'allow_new', array() ); + $result = $method->invoke( $this->ability, 'Test content', 'post_tag', array( 'php', 'wordpress' ) ); - $this->assertStringContainsString( 'Test content', $result ); - $this->assertStringContainsString( 'may suggest new terms', $result ); + $this->assertStringContainsString( 'php, wordpress', $result, 'Should include assigned terms' ); } /** - * Test that build_prompt() handles empty terms with existing_only strategy. + * Test that build_prompt() omits assigned terms tag when empty. * * @since x.x.x */ - public function test_build_prompt_empty_terms_existing_only() { + public function test_build_prompt_omits_empty_assigned_terms() { $reflection = new \ReflectionClass( $this->ability ); $method = $reflection->getMethod( 'build_prompt' ); $method->setAccessible( true ); - $result = $method->invoke( $this->ability, 'Test content', 'post_tag', 'existing_only', array() ); + $result = $method->invoke( $this->ability, 'Test content', 'post_tag', array() ); + + $this->assertStringNotContainsString( '', $result, 'Should not include assigned terms when empty' ); + } + + /** + * Test that parse_suggestions() filters out assigned terms. + * + * @since x.x.x + */ + public function test_parse_suggestions_filters_assigned_terms() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = '{"suggestions": [ + {"term": "php", "confidence": 0.9, "is_new": true}, + {"term": "javascript", "confidence": 0.8, "is_new": true}, + {"term": "python", "confidence": 0.7, "is_new": true} + ]}'; + + $result = $method->invoke( $this->ability, $response, array( 'php', 'javascript', 'python' ), 'allow_new', array( 'PHP' ), 10 ); + + $this->assertCount( 2, $result, 'Should exclude assigned term' ); + $this->assertEquals( 'javascript', $result[0]['term'] ); + $this->assertEquals( 'python', $result[1]['term'] ); + } + + /** + * Test that parse_suggestions() filters new terms for existing_only strategy. + * + * @since x.x.x + */ + public function test_parse_suggestions_existing_only_filters_new_terms() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_suggestions' ); + $method->setAccessible( true ); + + $response = '{"suggestions": [ + {"term": "php", "confidence": 0.9, "is_new": true}, + {"term": "brand new term", "confidence": 0.85, "is_new": true}, + {"term": "javascript", "confidence": 0.8, "is_new": true} + ]}'; + + $result = $method->invoke( $this->ability, $response, array( 'php', 'javascript' ), 'existing_only', array(), 10 ); - $this->assertStringContainsString( 'empty suggestions array', $result ); + $this->assertCount( 2, $result, 'Should only include existing terms' ); + $this->assertEquals( 'php', $result[0]['term'] ); + $this->assertEquals( 'javascript', $result[1]['term'] ); + $this->assertFalse( $result[0]['is_new'] ); + $this->assertFalse( $result[1]['is_new'] ); } /** @@ -488,13 +550,13 @@ public function test_suggestions_schema_returns_expected_structure() { $item_props = $schema['properties']['suggestions']['items']['properties']; $this->assertArrayHasKey( 'term', $item_props ); $this->assertArrayHasKey( 'confidence', $item_props ); - $this->assertArrayHasKey( 'is_new', $item_props ); + $this->assertArrayNotHasKey( 'is_new', $item_props, 'is_new is determined server-side, not by the LLM' ); $this->assertArrayHasKey( 'parent', $item_props ); $required = $schema['properties']['suggestions']['items']['required']; $this->assertContains( 'term', $required ); $this->assertContains( 'confidence', $required ); - $this->assertContains( 'is_new', $required ); + $this->assertNotContains( 'is_new', $required ); } /** From 550d10bc8faef35682a49c45655eb99b51f08f63 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Wed, 25 Mar 2026 09:00:16 -0400 Subject: [PATCH 58/72] Rename Contextual Tagging to Content Classification. Update all documenation, code references, directories, etc... --- ...l-tagging.md => content-classification.md} | 64 +++++++++--------- .../Content_Classification.php} | 24 +++---- .../system-instruction.php | 4 +- .../Content_Classification.php} | 38 +++++------ includes/Experiments/Experiments.php | 2 +- .../components/SuggestionPanel.tsx | 30 ++++----- .../components/useContentClassification.ts} | 24 +++---- .../index.scss | 2 +- .../index.tsx | 10 +-- .../types.ts | 12 ++-- ...est.php => Content_ClassificationTest.php} | 32 ++++----- .../Content_ClassificationTest.php} | 66 +++++++++---------- webpack.config.js | 4 +- 13 files changed, 156 insertions(+), 156 deletions(-) rename docs/experiments/{contextual-tagging.md => content-classification.md} (76%) rename includes/Abilities/{Contextual_Tagging/Contextual_Tagging.php => Content_Classification/Content_Classification.php} (94%) rename includes/Abilities/{Contextual_Tagging => Content_Classification}/system-instruction.php (90%) rename includes/Experiments/{Contextual_Tagging/Contextual_Tagging.php => Content_Classification/Content_Classification.php} (85%) rename src/experiments/{contextual-tagging => content-classification}/components/SuggestionPanel.tsx (79%) rename src/experiments/{contextual-tagging/components/useContextualTagging.ts => content-classification/components/useContentClassification.ts} (92%) rename src/experiments/{contextual-tagging => content-classification}/index.scss (98%) rename src/experiments/{contextual-tagging => content-classification}/index.tsx (79%) rename src/experiments/{contextual-tagging => content-classification}/types.ts (59%) rename tests/Integration/Includes/Abilities/{Contextual_TaggingTest.php => Content_ClassificationTest.php} (96%) rename tests/Integration/Includes/Experiments/{Contextual_Tagging/Contextual_TaggingTest.php => Content_Classification/Content_ClassificationTest.php} (68%) diff --git a/docs/experiments/contextual-tagging.md b/docs/experiments/content-classification.md similarity index 76% rename from docs/experiments/contextual-tagging.md rename to docs/experiments/content-classification.md index 2bf8bd0db..146aa7e24 100644 --- a/docs/experiments/contextual-tagging.md +++ b/docs/experiments/content-classification.md @@ -1,14 +1,14 @@ -# Contextual Tagging +# Content Classification ## Summary -The Contextual Tagging experiment adds AI-powered tag and category suggestions to the WordPress post editor. It analyzes post content and suggests relevant taxonomy terms directly within the Tags and Categories sidebar panels. The experiment registers a WordPress Ability (`ai/contextual-tagging`) that can be used both through the admin UI and directly via REST API requests. +The Content Classification experiment adds AI-powered tag and category suggestions to the WordPress post editor. It analyzes post content and suggests relevant taxonomy terms directly within the Tags and Categories sidebar panels. The experiment registers a WordPress Ability (`ai/content-classification`) that can be used both through the admin UI and directly via REST API requests. ## Overview ### For End Users -When enabled, the Contextual Tagging experiment adds "Suggest Tags" and "Suggest Categories" buttons to their respective panels in the post editor sidebar. Users can click these buttons to generate a list of AI-suggested terms based on the current post content. Suggestions appear as clickable pills that can be accepted (adding the term to the post) or dismissed. +When enabled, the Content Classification experiment adds "Suggest Tags" and "Suggest Categories" buttons to their respective panels in the post editor sidebar. Users can click these buttons to generate a list of AI-suggested terms based on the current post content. Suggestions appear as clickable pills that can be accepted (adding the term to the post) or dismissed. **Key Features:** @@ -25,8 +25,8 @@ When enabled, the Contextual Tagging experiment adds "Suggest Tags" and "Suggest The experiment consists of two main components: -1. **Experiment Class** (`WordPress\AI\Experiments\Contextual_Tagging\Contextual_Tagging`): Handles registration, asset enqueuing, settings, and UI integration -2. **Ability Class** (`WordPress\AI\Abilities\Contextual_Tagging\Contextual_Tagging`): Implements the core suggestion logic via the WordPress Abilities API +1. **Experiment Class** (`WordPress\AI\Experiments\Content_Classification\Content_Classification`): Handles registration, asset enqueuing, settings, and UI integration +2. **Ability Class** (`WordPress\AI\Abilities\Content_Classification\Content_Classification`): Implements the core suggestion logic via the WordPress Abilities API The ability can be called directly via REST API, making it useful for automation, bulk processing, or custom integrations. @@ -34,14 +34,14 @@ The ability can be called directly via REST API, making it useful for automation ### Key Hooks & Entry Points -- `WordPress\AI\Experiments\Contextual_Tagging\Contextual_Tagging::register()` wires everything once the experiment is enabled: - - `wp_abilities_api_init` → registers the `ai/contextual-tagging` ability (`includes/Abilities/Contextual_Tagging/Contextual_Tagging.php`) +- `WordPress\AI\Experiments\Content_Classification\Content_Classification::register()` wires everything once the experiment is enabled: + - `wp_abilities_api_init` → registers the `ai/content-classification` ability (`includes/Abilities/Content_Classification/Content_Classification.php`) - `admin_enqueue_scripts` → enqueues the React bundle and stylesheet on `post.php` and `post-new.php` screens for post types that support the editor ### Assets & Data Flow 1. **PHP Side:** - - `enqueue_assets()` loads `experiments/contextual-tagging` (`src/experiments/contextual-tagging/index.tsx`) and localizes `window.aiContextualTaggingData` with: + - `enqueue_assets()` loads `experiments/content-classification` (`src/experiments/content-classification/index.tsx`) and localizes `window.aiContentClassificationData` with: - `enabled`: Whether the experiment is enabled - `strategy`: The configured taxonomy strategy (`existing_only` or `allow_new`) - `maxSuggestions`: The configured maximum number of suggestions @@ -49,7 +49,7 @@ The ability can be called directly via REST API, making it useful for automation 2. **React Side:** - The React entry point (`index.tsx`) uses the `editor.PostTaxonomyType` filter via `addFilter` to wrap the native taxonomy selector components - `SuggestionPanel` component renders a generate button and suggestion pills for each supported taxonomy - - `useContextualTagging` hook: + - `useContentClassification` hook: - Gets current post ID and content from the editor store - Checks word count using `@wordpress/wordcount` (minimum 150 words) - Calls the ability via `runAbility()` when the button is clicked @@ -133,11 +133,11 @@ The ability checks permissions based on the input: The experiment registers two settings on the AI Experiments settings page: -- **Taxonomy strategy** (`wpai_experiment_contextual-tagging_field_strategy`): +- **Taxonomy strategy** (`wpai_experiment_content-classification_field_strategy`): - `existing_only` (default) — Only suggest terms that already exist on the site - `allow_new` — Allow suggestions for new terms based on content -- **Maximum suggestions** (`wpai_experiment_contextual-tagging_field_max_suggestions`): +- **Maximum suggestions** (`wpai_experiment_content-classification_field_max_suggestions`): - Integer between 1 and 10, default 5 ## Using the Ability via REST API @@ -145,7 +145,7 @@ The experiment registers two settings on the AI Experiments settings page: ### Endpoint ```text -POST /wp-json/wp-abilities/v1/abilities/ai/contextual-tagging/run +POST /wp-json/wp-abilities/v1/abilities/ai/content-classification/run ``` ### Authentication @@ -162,7 +162,7 @@ See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication in #### Example 1: Suggest Tags from Post ID ```bash -curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/contextual-tagging/run" \ +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/content-classification/run" \ -u "username:application-password" \ -H "Content-Type: application/json" \ -d '{ @@ -190,7 +190,7 @@ curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/contextu #### Example 2: Suggest Categories from Content String ```bash -curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/contextual-tagging/run" \ +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/content-classification/run" \ -u "username:application-password" \ -H "Content-Type: application/json" \ -d '{ @@ -210,7 +210,7 @@ import apiFetch from '@wordpress/api-fetch'; async function suggestTags( postId, taxonomy = 'post_tag' ) { try { const result = await apiFetch( { - path: '/wp-abilities/v1/abilities/ai/contextual-tagging/run', + path: '/wp-abilities/v1/abilities/ai/content-classification/run', method: 'POST', data: { input: { @@ -244,10 +244,10 @@ The ability may return the following error codes: ### Filtering Content Before AI Processing -Use the `wpai_contextual_tagging_content` filter to modify the content string before it is sent to the AI model: +Use the `wpai_content_classification_content` filter to modify the content string before it is sent to the AI model: ```php -add_filter( 'wpai_contextual_tagging_content', function( $content, $taxonomy, $strategy ) { +add_filter( 'wpai_content_classification_content', function( $content, $taxonomy, $strategy ) { // Add custom context to improve suggestions. if ( 'category' === $taxonomy ) { $content .= "\n\nSite focus: technology and science news."; @@ -258,10 +258,10 @@ add_filter( 'wpai_contextual_tagging_content', function( $content, $taxonomy, $s ### Filtering Suggestions After AI Processing -Use the `wpai_contextual_tagging_suggestions` filter to modify the parsed suggestions before they are returned: +Use the `wpai_content_classification_suggestions` filter to modify the parsed suggestions before they are returned: ```php -add_filter( 'wpai_contextual_tagging_suggestions', function( $suggestions, $taxonomy, $strategy ) { +add_filter( 'wpai_content_classification_suggestions', function( $suggestions, $taxonomy, $strategy ) { // Remove suggestions with low confidence. return array_filter( $suggestions, function( $s ) { return $s['confidence'] >= 0.7; @@ -273,12 +273,12 @@ add_filter( 'wpai_contextual_tagging_suggestions', function( $suggestions, $taxo ```php // Override the strategy programmatically. -add_filter( 'wpai_contextual_tagging_strategy', function( $strategy ) { +add_filter( 'wpai_content_classification_strategy', function( $strategy ) { return 'allow_new'; } ); // Override the max suggestions count. -add_filter( 'wpai_contextual_tagging_max_suggestions', function( $max ) { +add_filter( 'wpai_content_classification_max_suggestions', function( $max ) { return 7; } ); ``` @@ -318,7 +318,7 @@ add_filter( 'wpai_experiments_normalize_content', function( $content ) { 1. **Enable the experiment:** - Go to `Settings > AI Experiments` - - Toggle **Contextual Tagging** to enabled + - Toggle **Content Classification** to enabled - Configure the taxonomy strategy and max suggestions - Ensure you have valid AI credentials configured @@ -350,8 +350,8 @@ add_filter( 'wpai_experiments_normalize_content', function( $content ) { Tests are located in: -- `tests/Integration/Includes/Abilities/Contextual_TaggingTest.php` -- `tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php` +- `tests/Integration/Includes/Abilities/Content_ClassificationTest.php` +- `tests/Integration/Includes/Experiments/Content_Classification/Content_ClassificationTest.php` Run tests with: @@ -392,11 +392,11 @@ npm run test:php ## Related Files -- **Experiment:** `includes/Experiments/Contextual_Tagging/Contextual_Tagging.php` -- **Ability:** `includes/Abilities/Contextual_Tagging/Contextual_Tagging.php` -- **React Entry:** `src/experiments/contextual-tagging/index.tsx` -- **React Components:** `src/experiments/contextual-tagging/components/` -- **Styles:** `src/experiments/contextual-tagging/index.scss` -- **Types:** `src/experiments/contextual-tagging/types.ts` -- **Tests:** `tests/Integration/Includes/Abilities/Contextual_TaggingTest.php` -- **Tests:** `tests/Integration/Includes/Experiments/Contextual_Tagging/Contextual_TaggingTest.php` +- **Experiment:** `includes/Experiments/Content_Classification/Content_Classification.php` +- **Ability:** `includes/Abilities/Content_Classification/Content_Classification.php` +- **React Entry:** `src/experiments/content-classification/index.tsx` +- **React Components:** `src/experiments/content-classification/components/` +- **Styles:** `src/experiments/content-classification/index.scss` +- **Types:** `src/experiments/content-classification/types.ts` +- **Tests:** `tests/Integration/Includes/Abilities/Content_ClassificationTest.php` +- **Tests:** `tests/Integration/Includes/Experiments/Content_Classification/Content_ClassificationTest.php` diff --git a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php b/includes/Abilities/Content_Classification/Content_Classification.php similarity index 94% rename from includes/Abilities/Contextual_Tagging/Contextual_Tagging.php rename to includes/Abilities/Content_Classification/Content_Classification.php index d0e6e9de5..009b89ecb 100644 --- a/includes/Abilities/Contextual_Tagging/Contextual_Tagging.php +++ b/includes/Abilities/Content_Classification/Content_Classification.php @@ -1,32 +1,32 @@ array( 'type' => 'string', - 'default' => Contextual_Tagging_Experiment::STRATEGY_EXISTING_ONLY, + 'default' => Content_Classification_Experiment::STRATEGY_EXISTING_ONLY, 'sanitize_callback' => 'sanitize_key', 'description' => esc_html__( 'The suggestion strategy: existing_only or allow_new.', 'ai' ), ), @@ -65,7 +65,7 @@ protected function input_schema(): array { 'type' => 'integer', 'minimum' => 1, 'maximum' => 10, - 'default' => Contextual_Tagging_Experiment::DEFAULT_MAX_SUGGESTIONS, + 'default' => Content_Classification_Experiment::DEFAULT_MAX_SUGGESTIONS, 'sanitize_callback' => 'absint', 'description' => esc_html__( 'Maximum number of suggestions to generate.', 'ai' ), ), @@ -129,8 +129,8 @@ protected function execute_callback( $input ) { 'content' => null, 'post_id' => null, 'taxonomy' => 'post_tag', - 'strategy' => Contextual_Tagging_Experiment::STRATEGY_EXISTING_ONLY, - 'max_suggestions' => (int) Contextual_Tagging_Experiment::DEFAULT_MAX_SUGGESTIONS, + 'strategy' => Content_Classification_Experiment::STRATEGY_EXISTING_ONLY, + 'max_suggestions' => (int) Content_Classification_Experiment::DEFAULT_MAX_SUGGESTIONS, ), ); @@ -322,7 +322,7 @@ static function ( $key, $value ) { * @param string $taxonomy The taxonomy slug being suggested for (e.g., 'post_tag', 'category'). * @param array $assigned_terms Terms already assigned to the post. */ - $prompt = (string) apply_filters( 'wpai_contextual_tagging_prompt', $prompt, $context, $taxonomy, $assigned_terms ); + $prompt = (string) apply_filters( 'wpai_content_classification_prompt', $prompt, $context, $taxonomy, $assigned_terms ); // Generate the suggestions using the AI client with structured output. $result = wp_ai_client_prompt( $prompt ) @@ -364,7 +364,7 @@ static function ( $key, $value ) { * @param string $taxonomy The taxonomy slug (e.g., 'post_tag', 'category'). * @param string $strategy The suggestion strategy ('existing_only' or 'allow_new'). */ - return (array) apply_filters( 'wpai_contextual_tagging_suggestions', $suggestions, $taxonomy, $strategy ); + return (array) apply_filters( 'wpai_content_classification_suggestions', $suggestions, $taxonomy, $strategy ); } /** @@ -514,7 +514,7 @@ protected function parse_suggestions( string $response, array $existing_terms, s } // For existing_only strategy, skip terms that don't exist. - if ( Contextual_Tagging_Experiment::STRATEGY_EXISTING_ONLY === $strategy && $is_new ) { + if ( Content_Classification_Experiment::STRATEGY_EXISTING_ONLY === $strategy && $is_new ) { continue; } diff --git a/includes/Abilities/Contextual_Tagging/system-instruction.php b/includes/Abilities/Content_Classification/system-instruction.php similarity index 90% rename from includes/Abilities/Contextual_Tagging/system-instruction.php rename to includes/Abilities/Content_Classification/system-instruction.php index ed38d432a..4b730dbac 100644 --- a/includes/Abilities/Contextual_Tagging/system-instruction.php +++ b/includes/Abilities/Content_Classification/system-instruction.php @@ -1,8 +1,8 @@ __( 'Contextual Tagging', 'ai' ), + 'label' => __( 'Content Classification', 'ai' ), 'description' => __( 'AI-powered suggestions for post tags and categories based on content analysis.', 'ai' ), 'category' => Experiment_Category::EDITOR, ); @@ -93,7 +93,7 @@ public function register_abilities(): void { array( 'label' => $this->get_label(), 'description' => $this->get_description(), - 'ability_class' => Contextual_Tagging_Ability::class, + 'ability_class' => Content_Classification_Ability::class, ), ); } @@ -127,11 +127,11 @@ public function enqueue_assets( string $hook_suffix ): void { return; } - Asset_Loader::enqueue_script( 'contextual_tagging', 'experiments/contextual-tagging' ); - Asset_Loader::enqueue_style( 'contextual_tagging', 'experiments/contextual-tagging' ); + Asset_Loader::enqueue_script( 'content_classification', 'experiments/content-classification' ); + Asset_Loader::enqueue_style( 'content_classification', 'experiments/content-classification' ); Asset_Loader::localize_script( - 'contextual_tagging', - 'ContextualTaggingData', + 'content_classification', + 'ContentClassificationData', array( 'enabled' => $this->is_enabled(), 'strategy' => $this->get_strategy(), @@ -179,7 +179,7 @@ public function render_settings_fields(): void { $current_max = get_option( $this->get_field_option_name( 'max_suggestions' ), self::DEFAULT_MAX_SUGGESTIONS ); ?>
- +