diff --git a/includes/Abilities/Utilities/Posts.php b/includes/Abilities/Utilities/Posts.php index 1e5592ec..f7175b1b 100644 --- a/includes/Abilities/Utilities/Posts.php +++ b/includes/Abilities/Utilities/Posts.php @@ -153,6 +153,17 @@ private function register_get_post_details_ability(): void { $details['excerpt'] = $post->post_excerpt; } + /** + * Filters the post details returned by the get-post-details ability. + * + * @since 0.5.0 + * + * @param array $details The post details. + * @param int $post_id The post ID. + * @param array $fields The requested fields. + */ + $details = apply_filters( 'ai_experiments_get_post_details', $details, $post_id, $fields ); + // Return the post details. return $details; }, @@ -297,6 +308,17 @@ private function register_get_terms_ability(): void { ); } + /** + * Filters the terms returned by the get-post-terms ability. + * + * @since 0.5.0 + * + * @param array<\WP_Term> $terms The terms assigned to the post. + * @param int $post_id The post ID. + * @param array $allowed_taxonomies The allowed taxonomy names. + */ + $terms = apply_filters( 'ai_experiments_get_post_terms', $terms, $post_id, $allowed_taxonomies ); + return $terms; }, 'permission_callback' => array( $this, 'permission_callback' ), diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index 51a35ba0..fabc35ba 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -36,13 +36,58 @@ public function __construct( string $name, array $properties = array() ) { 'category' => $this->category(), 'input_schema' => $this->input_schema(), 'output_schema' => $this->output_schema(), - 'execute_callback' => array( $this, 'execute_callback' ), + 'execute_callback' => array( $this, 'filtered_execute_callback' ), 'permission_callback' => array( $this, 'permission_callback' ), 'meta' => $this->meta(), ) ); } + /** + * Wraps execute_callback to apply result filters. + * + * @since 0.5.0 + * + * @param mixed $input The input arguments to the ability. + * @return mixed|\WP_Error The filtered result of the ability execution, or a WP_Error on failure. + */ + public function filtered_execute_callback( $input ) { + $result = $this->execute_callback( $input ); + + // Don't filter WP_Error results. + if ( is_wp_error( $result ) ) { + return $result; + } + + $name = $this->get_name(); + + /** + * Filters the result of any ability execution. + * + * @since 0.5.0 + * + * @param mixed $result The result of the ability execution. + * @param string $name The name of the ability. + * @param mixed $input The input arguments to the ability. + */ + $result = apply_filters( 'ai_experiments_ability_result', $result, $name, $input ); + + /** + * Filters the result of a specific ability execution. + * + * The dynamic portion of the hook name, `$name`, refers to the ability name + * (e.g., 'ai/title-generation', 'ai/summarization'). + * + * @since 0.5.0 + * + * @param mixed $result The result of the ability execution. + * @param mixed $input The input arguments to the ability. + */ + $result = apply_filters( "ai_experiments_ability_result_{$name}", $result, $input ); + + return $result; + } + /** * Returns the category of the ability. * @@ -125,7 +170,18 @@ abstract protected function meta(): array; * @return string The system instruction for the feature. */ public function get_system_instruction( ?string $filename = null, array $data = array() ): string { - return $this->load_system_instruction_from_file( $filename, $data ); + $instruction = $this->load_system_instruction_from_file( $filename, $data ); + + /** + * Filters the system instruction for an ability. + * + * @since 0.5.0 + * + * @param string $instruction The system instruction text. + * @param string $name The name of the ability. + * @param array $data The data passed to the system instruction file. + */ + return apply_filters( 'ai_experiments_system_instruction', $instruction, $this->get_name(), $data ); } /** diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php index 41e97baf..8eba731e 100644 --- a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -446,4 +446,184 @@ public function test_get_system_instruction_with_empty_data_array() { } } } + + /** + * Test that ai_experiments_system_instruction filter modifies system instructions. + * + * @since 0.5.0 + */ + public function test_system_instruction_filter() { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $filter_callback = function ( $instruction, $name, $data ) { + return $instruction . ' Appended by filter.'; + }; + + add_filter( 'ai_experiments_system_instruction', $filter_callback, 10, 3 ); + + $result = $ability->get_system_instruction(); + + remove_filter( 'ai_experiments_system_instruction', $filter_callback, 10 ); + + $this->assertStringContainsString( 'Appended by filter.', $result, 'System instruction filter should modify the instruction' ); + } + + /** + * Test that ai_experiments_system_instruction filter receives the correct ability name. + * + * @since 0.5.0 + */ + public function test_system_instruction_filter_receives_ability_name() { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $captured_name = null; + $filter_callback = function ( $instruction, $name ) use ( &$captured_name ) { + $captured_name = $name; + return $instruction; + }; + + add_filter( 'ai_experiments_system_instruction', $filter_callback, 10, 2 ); + + $ability->get_system_instruction(); + + remove_filter( 'ai_experiments_system_instruction', $filter_callback, 10 ); + + $this->assertSame( 'test-ability', $captured_name, 'Filter should receive the ability name' ); + } + + /** + * Test that ai_experiments_ability_result filter modifies ability results. + * + * @since 0.5.0 + */ + public function test_ability_result_filter() { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $filter_callback = function ( $result, $name, $input ) { + $result['filtered'] = true; + return $result; + }; + + add_filter( 'ai_experiments_ability_result', $filter_callback, 10, 3 ); + + $result = $ability->filtered_execute_callback( array() ); + + remove_filter( 'ai_experiments_ability_result', $filter_callback, 10 ); + + $this->assertArrayHasKey( 'filtered', $result, 'Result filter should add key to result' ); + $this->assertTrue( $result['filtered'], 'Result filter should set filtered to true' ); + } + + /** + * Test that the dynamic ability result filter works. + * + * @since 0.5.0 + */ + public function test_ability_result_dynamic_filter() { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $filter_callback = function ( $result, $input ) { + $result['specific'] = true; + return $result; + }; + + add_filter( 'ai_experiments_ability_result_test-ability', $filter_callback, 10, 2 ); + + $result = $ability->filtered_execute_callback( array() ); + + remove_filter( 'ai_experiments_ability_result_test-ability', $filter_callback, 10 ); + + $this->assertArrayHasKey( 'specific', $result, 'Dynamic result filter should add key to result' ); + $this->assertTrue( $result['specific'], 'Dynamic result filter should set specific to true' ); + } + + /** + * Test that WP_Error results are not filtered. + * + * @since 0.5.0 + */ + public function test_wp_error_not_filtered() { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Error_Ability( + 'test-error-ability', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $filter_called = false; + $filter_callback = function ( $result ) use ( &$filter_called ) { + $filter_called = true; + return $result; + }; + + add_filter( 'ai_experiments_ability_result', $filter_callback, 10, 1 ); + + $result = $ability->filtered_execute_callback( array() ); + + remove_filter( 'ai_experiments_ability_result', $filter_callback, 10 ); + + $this->assertTrue( is_wp_error( $result ), 'WP_Error should be returned as-is' ); + $this->assertFalse( $filter_called, 'Result filter should not be called for WP_Error' ); + } +} + +/** + * Test ability that returns WP_Error for testing error bypass. + * + * @since 0.5.0 + */ +class Test_Error_Ability extends Abstract_Ability { + protected function category(): string { + return 'test-category'; + } + + protected function input_schema(): array { + return array( 'type' => 'object', 'properties' => array() ); + } + + protected function output_schema(): array { + return array( 'type' => 'object', 'properties' => array() ); + } + + protected function execute_callback( $input ) { + return new \WP_Error( 'test_error', 'Test error message' ); + } + + protected function permission_callback( $input ) { + return true; + } + + protected function meta(): array { + return array(); + } }