From ce7002cb985488ed62b13ac30f2f43a786e24003 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 15:29:33 -0600 Subject: [PATCH 01/43] Set the category from the parent class as we'll likely use the same category for each ability --- includes/Abilities/Title_Generation.php | 11 ----------- includes/Abstracts/Abstract_Ability.php | 4 +++- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 199c1a43..02a243b5 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -17,17 +17,6 @@ */ class Title_Generation extends Abstract_Ability { - /** - * Returns the category of the ability. - * - * @since 0.1.0 - * - * @return string The category of the ability. - */ - protected function category(): string { - return 'ai-experiments'; // TODO: add a reusable way to get the category slug? - } - /** * Returns the input schema of the ability. * diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index cea97ee6..b0c493ab 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -79,7 +79,9 @@ protected function description(): string { * * @return string The category of the ability. */ - abstract protected function category(): string; + protected function category(): string { + return 'ai-experiments'; + } /** * Returns the input schema of the ability. From 76da245a90d05d1ccfadccb0c54979589f8326b9 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 16:34:30 -0600 Subject: [PATCH 02/43] Add a generate titles method to our feature. Use this in our ability when it is run --- includes/Abilities/Title_Generation.php | 28 +++++++++------ .../Title_Generation/Title_Generation.php | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 02a243b5..9b1e10fd 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -17,6 +17,14 @@ */ class Title_Generation extends Abstract_Ability { + /** + * The Feature class that the ability belongs to. + * + * @since 0.1.0 + * @var \WordPress\AI\Features\Title_Generation\Title_Generation + */ + protected $feature; + /** * Returns the input schema of the ability. * @@ -113,18 +121,16 @@ protected function execute_callback( $input ) { ); } - // TODO: Implement the title generation logic. + // Generate the titles. + $result = $this->feature->generate_titles( $args['content'], $args['n'] ); - return array( - 'feature_id' => $this->feature->get_id(), - 'label' => $this->feature->get_label(), - 'description' => $this->feature->get_description(), - 'enabled' => $this->feature->is_enabled(), - 'content' => wp_kses_post( $args['content'] ), - 'post_id' => absint( $args['post_id'] ) ?? esc_html__( 'Not provided', 'ai' ), - 'n' => absint( $args['n'] ), - 'message' => esc_html__( 'Title generation feature is active', 'ai' ), - ); + // If we have an error, return it. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the titles in the format the Ability expects. + return [ 'titles' => $result ]; } /** diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 030c180c..2f4dc502 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -9,6 +9,7 @@ use WordPress\AI\Abilities\Title_Generation as Title_Generation_Ability; use WordPress\AI\Abstracts\Abstract_Feature; +use WordPress\AI\API_Request; /** * Title generation feature. @@ -56,4 +57,39 @@ public function register_abilities(): void { ), ); } + + /** + * Generates title suggestions from the given content. + * + * @since 0.1.0 + * + * @param string $content The content to generate a title from. + * @param int|null $post_id The post ID to generate a title from. + * @param int $n The number of titles to generate. + * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. + */ + public function generate_titles( string $content, int $n = 1 ) { + $prompt = sprintf( + __( 'Generate %d title suggestions for the following content:', 'ai' ), // TODO: add method to get this. And update this default prompt. + $n + ); + $prompt .= "\n\n" . $content; + + // Make our request. + $request = new API_Request(); + $response = $request->request( + $prompt, + array( + 'candidateCount' => (int) $n, + 'temperature' => 0.7, + ) + ); + + // If we have an error, return it. + if ( is_wp_error( $response ) ) { + return $response; + } + + return $response; + } } From e86c1b4eba0a58757f6c3d2233714f69f7d08c40 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 16:34:51 -0600 Subject: [PATCH 03/43] Add an API Request class that can be used to make API requests. Use the AI Client to actually make the requests --- includes/API_Request.php | 173 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 includes/API_Request.php diff --git a/includes/API_Request.php b/includes/API_Request.php new file mode 100644 index 00000000..bd51a484 --- /dev/null +++ b/includes/API_Request.php @@ -0,0 +1,173 @@ +provider = $provider ?? null; + $this->model = $model ?? null; + } + + /** + * Make a request using the AI SDK Client. + * + * @since 0.1.0 + * + * @param string|null $prompt The prompt to send. + * @param array $options The options to send. + * @return array|\WP_Error The result of the request. + */ + public function request( $prompt = null, array $options = [] ) { + if ( ! $this->is_client_available() ) { + return new WP_Error( 'ai_client_not_available', __( 'AI Client is not available', 'ai' ) ); + } + + try { + $model_config = $this->process_model_config( $options ); + $prompt_builder = AiClient::prompt( $prompt ); + $prompt_builder = $prompt_builder->usingModelConfig( $model_config ); + + if ( ! empty( $this->provider ) ) { + $prompt_builder = $prompt_builder->usingProvider( $this->provider ); + + // Set the model. + if ( ! empty( $this->model ) ) { + $registry = AiClient::defaultRegistry(); + $provider_class_name = $registry->getProviderClassName( $this->provider ); + $prompt_builder = $prompt_builder->usingModel( $provider_class_name::model( $this->model ) ); + } + } + + return $this->get_result( $prompt_builder->generateTexts(), 'text' ); + } catch ( \Exception $e ) { + return new WP_Error( 'ai_client_error', $e->getMessage() ); + } + } + + /** + * Process the response from the AI SDK Client. + * + * @since 0.1.0 + * + * @param array $response The response from the AI SDK Client. + * @return array|\WP_Error + */ + protected function get_result( array $response ) { + if ( empty( $response ) ) { + return new WP_Error( 'no_choices', __( 'No choices were returned from the AI provider', 'ai' ) ); + } + + $results = []; + foreach ( $response as $choice ) { + $results[] = $this->sanitize_choice( $choice ); + } + + return $results; + } + + /** + * Sanitize a choice from AI response. + * + * @since 0.1.0 + * + * @param string $choice The choice to sanitize. + * @return string + */ + protected function sanitize_choice( string $choice ): string { + return sanitize_text_field( trim( $choice, ' "\'' ) ); + } + + /** + * Process the model config. + * + * @since 0.1.0 + * + * @param array $options The options to add to the model config. + * @return ModelConfig + */ + protected function process_model_config( array $options ): ModelConfig { + $schema = ModelConfig::getJsonSchema()['properties']; + $model_config = []; + + foreach ( $options as $key => $value ) { + if ( ! isset( $schema[ $key ] ) ) { + continue; + } + + $property_schema = $schema[ $key ]; + $type = $property_schema['type'] ?? null; + + $processed_value = (string) $value; + + if ( 'array' === $type || 'object' === $type ) { + $processed_value = (array) $value; + } elseif ( 'integer' === $type ) { + $processed_value = (int) $value; + } elseif ( 'number' === $type ) { + $processed_value = (float) $value; + } elseif ( 'boolean' === $type ) { + $processed_value = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); + + if ( null === $processed_value ) { + continue; + } + } + + $model_config[ $key ] = $processed_value; + } + + return ModelConfig::fromArray( $model_config ); + } + + /** + * Check if the AI SDK Client is available. + * + * @since 0.1.0 + * + * @return bool True if the client is available, false otherwise. + */ + protected function is_client_available(): bool { + return class_exists( AiClient::class ); + } +} From 64062f33a6e960d61562680391df6a9f83204848 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 16:35:02 -0600 Subject: [PATCH 04/43] Install guzzle so things work --- composer.json | 4 +- composer.lock | 438 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 439 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index e5b53a0c..6ceada54 100644 --- a/composer.json +++ b/composer.json @@ -6,10 +6,11 @@ "require": { "automattic/jetpack-autoloader": "^5.0", "ext-json": "*", + "guzzlehttp/guzzle": "^7.10", "php": ">=7.4", "wordpress/abilities-api": "^0.4.0", "wordpress/mcp-adapter": "dev-trunk", - "wordpress/wp-ai-client": "dev-trunk" + "wordpress/wp-ai-client": "dev-trunk", }, "require-dev": { "automattic/vipwpcs": "^3.0", @@ -47,7 +48,6 @@ "dealerdirect/phpcodesniffer-composer-installer": true, "php-http/discovery": true, "phpstan/extension-installer": true - }, "platform": { "php": "7.4" diff --git a/composer.lock b/composer.lock index e965116b..c1210803 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f275018de0bc539ae98c534ab9e40c5a", + "content-hash": "58ac7ac3edf61480d11831a160ce5aee", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -71,6 +71,331 @@ }, "time": "2025-10-06T10:32:52+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -474,6 +799,117 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "2.5-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:11:13+00:00" + }, { "name": "wordpress/abilities-api", "version": "v0.4.0", From 6afdbf631878703c14d2dc370d4ada8b7171b605 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 13:04:43 -0700 Subject: [PATCH 05/43] Switch to the settings branch of the WP AI Client repo so we can more easily set credentials. Initialize the settings screen --- composer.json | 2 +- composer.lock | 68 +++++++++++++++++++++--------------------- includes/bootstrap.php | 5 ++++ 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/composer.json b/composer.json index 6ceada54..2c8f851a 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "php": ">=7.4", "wordpress/abilities-api": "^0.4.0", "wordpress/mcp-adapter": "dev-trunk", - "wordpress/wp-ai-client": "dev-trunk", + "wordpress/wp-ai-client": "dev-add/api-credentials-management" }, "require-dev": { "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index c1210803..f381cf0a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "58ac7ac3edf61480d11831a160ce5aee", + "content-hash": "5530fc4f50dc724598613d2e677d5a5e", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -989,12 +989,12 @@ "source": { "type": "git", "url": "https://github.com/WordPress/mcp-adapter.git", - "reference": "0e2910780a95ac855931a4df7b0a9de7ca7b05eb" + "reference": "b55221f9d31307ae69943a094394a126579017f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/0e2910780a95ac855931a4df7b0a9de7ca7b05eb", - "reference": "0e2910780a95ac855931a4df7b0a9de7ca7b05eb", + "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/b55221f9d31307ae69943a094394a126579017f6", + "reference": "b55221f9d31307ae69943a094394a126579017f6", "shasum": "" }, "require": { @@ -1056,20 +1056,20 @@ "issues": "https://github.com/wordpress/mcp-adapter/issues", "source": "https://github.com/wordpress/mcp-adapter" }, - "time": "2025-10-23T05:56:26+00:00" + "time": "2025-11-03T17:53:17+00:00" }, { "name": "wordpress/php-ai-client", - "version": "0.1.0", + "version": "0.2.0", "source": { "type": "git", "url": "https://github.com/WordPress/php-ai-client.git", - "reference": "9ec56e70e692791493a3eaff1b69f25f4daeded7" + "reference": "81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/9ec56e70e692791493a3eaff1b69f25f4daeded7", - "reference": "9ec56e70e692791493a3eaff1b69f25f4daeded7", + "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2", + "reference": "81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2", "shasum": "" }, "require": { @@ -1123,37 +1123,37 @@ "issues": "https://github.com/WordPress/php-ai-client/issues", "source": "https://github.com/WordPress/php-ai-client" }, - "time": "2025-08-29T22:46:54+00:00" + "time": "2025-10-21T00:05:14+00:00" }, { "name": "wordpress/wp-ai-client", - "version": "dev-trunk", + "version": "dev-add/api-credentials-management", "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "3d8a386ad2988fba88fa6f44dd1045a58e0c8f30" + "reference": "0b05a0717edb68402c60fd51ecec3d622e8d8000" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/3d8a386ad2988fba88fa6f44dd1045a58e0c8f30", - "reference": "3d8a386ad2988fba88fa6f44dd1045a58e0c8f30", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/0b05a0717edb68402c60fd51ecec3d622e8d8000", + "reference": "0b05a0717edb68402c60fd51ecec3d622e8d8000", "shasum": "" }, "require": { "ext-json": "*", "php": ">=7.4", - "wordpress/php-ai-client": "^0.1" + "wordpress/php-ai-client": "^0.2" }, "require-dev": { "automattic/vipwpcs": "^3.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "phpcompatibility/phpcompatibility-wp": "^2.1", - "phpstan/phpstan": "~2.1", + "phpstan/phpstan": "^1.10 | ^2.1", "slevomat/coding-standard": "^8.0", "squizlabs/php_codesniffer": "^3.7", + "szepeviktor/phpstan-wordpress": "^1.3", "wp-coding-standards/wpcs": "^3.0" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1172,7 +1172,7 @@ "phpcbf" ], "phpstan": [ - "phpstan analyze --memory-limit=256M" + "phpstan analyze --memory-limit=1024M" ] }, "license": [ @@ -1196,7 +1196,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-09-08T16:26:36+00:00" + "time": "2025-10-29T22:56:39+00:00" } ], "packages-dev": [ @@ -1804,16 +1804,16 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.8.2", + "version": "v6.8.3", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8" + "reference": "abeb5a8b58fda7ac21f15ee596f302f2959a7114" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", - "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/abeb5a8b58fda7ac21f15ee596f302f2959a7114", + "reference": "abeb5a8b58fda7ac21f15ee596f302f2959a7114", "shasum": "" }, "conflict": { @@ -1849,9 +1849,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.8.2" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.8.3" }, - "time": "2025-07-16T06:41:00+00:00" + "time": "2025-09-30T20:58:47+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -1859,12 +1859,12 @@ "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", - "reference": "4423c33f26c907cad43582df7ec02706c523856d" + "reference": "ff4efdd80e094a81fd6329b570c9a632f21d42b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/4423c33f26c907cad43582df7ec02706c523856d", - "reference": "4423c33f26c907cad43582df7ec02706c523856d", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/ff4efdd80e094a81fd6329b570c9a632f21d42b4", + "reference": "ff4efdd80e094a81fd6329b570c9a632f21d42b4", "shasum": "" }, "require": { @@ -1945,7 +1945,7 @@ "type": "thanks_dev" } ], - "time": "2025-10-27T20:28:17+00:00" + "time": "2025-10-30T20:24:19+00:00" }, { "name": "phpcompatibility/phpcompatibility-paragonie", @@ -2100,16 +2100,16 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.4.1", + "version": "1.4.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "882b8c947ada27eb002870fe77fee9ce0a454cdb" + "reference": "8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/882b8c947ada27eb002870fe77fee9ce0a454cdb", - "reference": "882b8c947ada27eb002870fe77fee9ce0a454cdb", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e", + "reference": "8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e", "shasum": "" }, "require": { @@ -2178,7 +2178,7 @@ "type": "thanks_dev" } ], - "time": "2025-09-05T06:54:52+00:00" + "time": "2025-10-28T17:00:02+00:00" }, { "name": "phpcsstandards/phpcsutils", diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 71dff9fd..b177cbce 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -9,6 +9,8 @@ namespace WordPress\AI; +use WordPress\AI_Client\API_Credentials\API_Credentials_Manager; + // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; @@ -173,6 +175,9 @@ function initialize_features(): void { $loader->register_default_features(); $loader->initialize_features(); + $api_credentials_manager = new API_Credentials_Manager(); + $api_credentials_manager->initialize(); + add_action( 'wp_abilities_api_categories_init', static function () { From c08f2920f1ee40b357376728358c7a0348500756 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 13:26:03 -0700 Subject: [PATCH 06/43] Add the ability to set system instructions at the feature level. Pass this into our request method --- includes/API_Request.php | 11 ++++++++--- includes/Abstracts/Abstract_Feature.php | 19 +++++++++++++++++++ includes/Contracts/Feature.php | 9 +++++++++ .../Title_Generation/Title_Generation.php | 19 +++++++++++-------- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index bd51a484..de7e4f8d 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -50,15 +50,16 @@ public function __construct( string $provider = '', string $model = '' ) { } /** - * Make a request using the AI SDK Client. + * Make a text generation request using the AI SDK Client. * * @since 0.1.0 * * @param string|null $prompt The prompt to send. + * @param string|null $system_instruction The system instruction to send. * @param array $options The options to send. * @return array|\WP_Error The result of the request. */ - public function request( $prompt = null, array $options = [] ) { + public function generate_text( $prompt = null, $system_instruction = null, array $options = [] ) { if ( ! $this->is_client_available() ) { return new WP_Error( 'ai_client_not_available', __( 'AI Client is not available', 'ai' ) ); } @@ -68,6 +69,10 @@ public function request( $prompt = null, array $options = [] ) { $prompt_builder = AiClient::prompt( $prompt ); $prompt_builder = $prompt_builder->usingModelConfig( $model_config ); + if ( ! empty( $system_instruction ) ) { + $prompt_builder = $prompt_builder->usingSystemInstruction( $system_instruction ); + } + if ( ! empty( $this->provider ) ) { $prompt_builder = $prompt_builder->usingProvider( $this->provider ); @@ -79,7 +84,7 @@ public function request( $prompt = null, array $options = [] ) { } } - return $this->get_result( $prompt_builder->generateTexts(), 'text' ); + return $this->get_result( $prompt_builder->generateTexts() ); } catch ( \Exception $e ) { return new WP_Error( 'ai_client_error', $e->getMessage() ); } diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Feature.php index 5a384420..fc3d17c7 100644 --- a/includes/Abstracts/Abstract_Feature.php +++ b/includes/Abstracts/Abstract_Feature.php @@ -50,6 +50,14 @@ abstract class Abstract_Feature implements Feature { */ private $enabled = true; + /** + * System instruction to send to the LLM. + * + * @since 0.1.0 + * @var string + */ + protected $system_instruction = ''; + /** * Constructor. * @@ -140,6 +148,17 @@ public function get_ability_slug(): string { return 'ai/' . $this->id; } + /** + * Gets the system instruction for the feature. + * + * @since 0.1.0 + * + * @return string The system instruction for the feature. + */ + public function get_system_instruction(): string { + return $this->system_instruction; + } + /** * Checks if feature is enabled. * diff --git a/includes/Contracts/Feature.php b/includes/Contracts/Feature.php index f574bcd0..dfcb05a9 100644 --- a/includes/Contracts/Feature.php +++ b/includes/Contracts/Feature.php @@ -57,6 +57,15 @@ public function get_description(): string; */ public function get_ability_slug(): string; + /** + * Gets the system instruction for the feature. + * + * @since 0.1.0 + * + * @return string The system instruction for the feature. + */ + public function get_system_instruction(): string; + /** * Registers the feature's hooks and functionality. * diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 2f4dc502..4f55884c 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -18,6 +18,14 @@ */ class Title_Generation extends Abstract_Feature { + /** + * System instruction the feature uses. + * + * @since 0.1.0 + * @var string + */ + protected $system_instruction = 'Generate an SEO-friendly title for the provided content, staying within a range of 40 to 60 characters and maintaining the original meaning and context. The content you will be provided is delimited by triple quotes.'; // TODO: tune this prompt. + /** * Load feature metadata. * @@ -69,16 +77,11 @@ public function register_abilities(): void { * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ public function generate_titles( string $content, int $n = 1 ) { - $prompt = sprintf( - __( 'Generate %d title suggestions for the following content:', 'ai' ), // TODO: add method to get this. And update this default prompt. - $n - ); - $prompt .= "\n\n" . $content; - // Make our request. $request = new API_Request(); - $response = $request->request( - $prompt, + $response = $request->generate_text( + '"""'. $content . '"""', + $this->get_system_instruction(), array( 'candidateCount' => (int) $n, 'temperature' => 0.7, From ec8c3b812b3ffad2183419f4a15f278c016699d2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 14:16:15 -0700 Subject: [PATCH 07/43] Add a prompt builder method to our API Request class --- includes/API_Request.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index de7e4f8d..5af68fff 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -64,6 +64,26 @@ public function generate_text( $prompt = null, $system_instruction = null, array return new WP_Error( 'ai_client_not_available', __( 'AI Client is not available', 'ai' ) ); } + $prompt_builder = $this->prompt_builder( $prompt, $system_instruction, $options ); + + if ( is_wp_error( $prompt_builder ) ) { + return $prompt_builder; + } + + return $this->get_result( $prompt_builder->generateTexts() ); + } + + /** + * Build the prompt builder for the request. + * + * @since 0.1.0 + * + * @param string|null $prompt The prompt to send. + * @param string|null $system_instruction The system instruction to send. + * @param array $options The options to send. + * @return \WordPress\AiClient\PromptBuilder|\WP_Error The prompt builder or a WP_Error. + */ + protected function prompt_builder( $prompt = null, $system_instruction = null, array $options = [] ) { try { $model_config = $this->process_model_config( $options ); $prompt_builder = AiClient::prompt( $prompt ); @@ -84,7 +104,7 @@ public function generate_text( $prompt = null, $system_instruction = null, array } } - return $this->get_result( $prompt_builder->generateTexts() ); + return $prompt_builder; } catch ( \Exception $e ) { return new WP_Error( 'ai_client_error', $e->getMessage() ); } From ff160511c2d9f0ab17f6a5351eed8841eb595279 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 14:29:24 -0700 Subject: [PATCH 08/43] Add unit tests to cover changes --- .../Integration/Includes/API_RequestTest.php | 258 ++++++++++++++++++ .../Abilities/Title_GenerationTest.php | 41 ++- .../Example_Feature/Example_FeatureTest.php | 14 + .../Title_Generation/Title_GenerationTest.php | 127 +++++++++ 4 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 tests/Integration/Includes/API_RequestTest.php diff --git a/tests/Integration/Includes/API_RequestTest.php b/tests/Integration/Includes/API_RequestTest.php new file mode 100644 index 00000000..e09e8761 --- /dev/null +++ b/tests/Integration/Includes/API_RequestTest.php @@ -0,0 +1,258 @@ +getProperty( 'provider' ); + $provider_property->setAccessible( true ); + $model_property = $reflection->getProperty( 'model' ); + $model_property->setAccessible( true ); + + $this->assertEquals( 'openai', $provider_property->getValue( $request ), 'Provider should be set' ); + $this->assertEquals( 'gpt-4', $model_property->getValue( $request ), 'Model should be set' ); + } + + /** + * Test that constructor handles empty strings. + * + * @since 0.1.0 + */ + public function test_constructor_handles_empty_strings() { + $request = new API_Request( '', '' ); + + $reflection = new \ReflectionClass( $request ); + $provider_property = $reflection->getProperty( 'provider' ); + $provider_property->setAccessible( true ); + $model_property = $reflection->getProperty( 'model' ); + $model_property->setAccessible( true ); + + $this->assertEquals( '', $provider_property->getValue( $request ), 'Provider should be empty string' ); + $this->assertEquals( '', $model_property->getValue( $request ), 'Model should be empty string' ); + } + + /** + * Test that is_client_available() checks for AiClient class. + * + * @since 0.1.0 + */ + public function test_is_client_available_checks_class() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'is_client_available' ); + $method->setAccessible( true ); + + $result = $method->invoke( $request ); + + // The result depends on whether AiClient class exists in the test environment. + $this->assertIsBool( $result, 'Should return a boolean' ); + } + + /** + * Test that generate_text() returns error when client is not available. + * + * @since 0.1.0 + */ + public function test_generate_text_returns_error_when_client_unavailable() { + $request = new API_Request(); + + // Mock the is_client_available method to return false. + $mock = $this->getMockBuilder( API_Request::class ) + ->onlyMethods( array( 'is_client_available' ) ) + ->getMock(); + + $mock->expects( $this->once() ) + ->method( 'is_client_available' ) + ->willReturn( false ); + + $result = $mock->generate_text( 'test prompt' ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error when client unavailable' ); + $this->assertEquals( 'ai_client_not_available', $result->get_error_code(), 'Error code should be ai_client_not_available' ); + } + + /** + * Test that sanitize_choice() sanitizes text correctly. + * + * @since 0.1.0 + */ + public function test_sanitize_choice_sanitizes_text() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'sanitize_choice' ); + $method->setAccessible( true ); + + // Test trimming quotes. + $result = $method->invoke( $request, '"Test Title"' ); + $this->assertEquals( 'Test Title', $result, 'Should remove double quotes' ); + + $result = $method->invoke( $request, "'Test Title'" ); + $this->assertEquals( 'Test Title', $result, 'Should remove single quotes' ); + + // Test trimming whitespace. + $result = $method->invoke( $request, ' Test Title ' ); + $this->assertEquals( 'Test Title', $result, 'Should trim whitespace' ); + + // Test sanitize_text_field behavior (removes HTML). + $result = $method->invoke( $request, 'Test Title' ); + $this->assertEquals( 'Test Title', $result, 'Should sanitize HTML' ); + } + + /** + * Test that get_result() returns error for empty response. + * + * @since 0.1.0 + */ + public function test_get_result_returns_error_for_empty_response() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'get_result' ); + $method->setAccessible( true ); + + $result = $method->invoke( $request, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for empty response' ); + $this->assertEquals( 'no_choices', $result->get_error_code(), 'Error code should be no_choices' ); + } + + /** + * Test that get_result() processes choices correctly. + * + * @since 0.1.0 + */ + public function test_get_result_processes_choices() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'get_result' ); + $method->setAccessible( true ); + + $response = array( + ' Title 1 ', + '"Title 2"', + "'Title 3'", + ); + + $result = $method->invoke( $request, $response ); + + $this->assertIsArray( $result, 'Should return an array' ); + $this->assertCount( 3, $result, 'Should have 3 choices' ); + $this->assertEquals( 'Title 1', $result[0], 'Should sanitize first choice' ); + $this->assertEquals( 'Title 2', $result[1], 'Should sanitize second choice' ); + $this->assertEquals( 'Title 3', $result[2], 'Should sanitize third choice' ); + } + + /** + * Test that process_model_config() processes string values. + * + * @since 0.1.0 + */ + public function test_process_model_config_processes_string_values() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'process_model_config' ); + $method->setAccessible( true ); + + $options = array( + 'temperature' => '0.7', + ); + + $result = $method->invoke( $request, $options ); + + $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); + } + + /** + * Test that process_model_config() processes integer values. + * + * @since 0.1.0 + */ + public function test_process_model_config_processes_integer_values() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'process_model_config' ); + $method->setAccessible( true ); + + $options = array( + 'candidateCount' => '5', + ); + + $result = $method->invoke( $request, $options ); + + $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); + } + + /** + * Test that process_model_config() processes boolean values. + * + * @since 0.1.0 + */ + public function test_process_model_config_processes_boolean_values() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'process_model_config' ); + $method->setAccessible( true ); + + $options = array( + 'someBoolean' => 'true', + ); + + // This will only work if 'someBoolean' is in the ModelConfig schema. + // Otherwise it will be skipped. We'll just verify it doesn't error. + $result = $method->invoke( $request, $options ); + + $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); + } + + /** + * Test that process_model_config() skips invalid options. + * + * @since 0.1.0 + */ + public function test_process_model_config_skips_invalid_options() { + $request = new API_Request(); + + $reflection = new \ReflectionClass( $request ); + $method = $reflection->getMethod( 'process_model_config' ); + $method->setAccessible( true ); + + $options = array( + 'invalid_option' => 'value', + 'temperature' => '0.7', + ); + + $result = $method->invoke( $request, $options ); + + $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); + } +} + diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index 42974ec5..f231f950 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -41,6 +41,24 @@ protected function load_feature_metadata(): array { public function register(): void { // No-op for testing. } + + /** + * Generates title suggestions from the given content. + * + * @since 0.1.0 + * + * @param string $content The content to generate a title from. + * @param int $n The number of titles to generate. + * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. + */ + public function generate_titles( string $content, int $n = 1 ) { + // For testing, return mock titles. + $titles = array(); + for ( $i = 1; $i <= $n; $i++ ) { + $titles[] = "Generated Title {$i}"; + } + return $titles; + } } /** @@ -175,11 +193,10 @@ public function test_execute_callback_with_content() { $result = $method->invoke( $this->ability, $input ); $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 'title-generation', $result['feature_id'], 'Feature ID should match' ); - $this->assertEquals( 'Title Generation', $result['label'], 'Label should match' ); - $this->assertEquals( 'Generates title suggestions from content', $result['description'], 'Description should match' ); - $this->assertEquals( 'This is some test content.', $result['content'], 'Content should match input' ); - $this->assertEquals( 3, $result['n'], 'n should match input' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + $this->assertCount( 3, $result['titles'], 'Should have 3 titles' ); + $this->assertEquals( 'Generated Title 1', $result['titles'][0], 'First title should match' ); } /** @@ -207,8 +224,9 @@ public function test_execute_callback_with_post_id() { $result = $method->invoke( $this->ability, $input ); $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 'This is post content.', $result['content'], 'Content should come from post' ); - $this->assertEquals( $post_id, $result['post_id'], 'Post ID should match' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + $this->assertCount( 2, $result['titles'], 'Should have 2 titles' ); } /** @@ -263,7 +281,9 @@ public function test_execute_callback_uses_defaults() { $result = $method->invoke( $this->ability, $input ); $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 1, $result['n'], 'n should default to 1' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + $this->assertCount( 1, $result['titles'], 'Should have 1 title by default' ); } /** @@ -291,7 +311,10 @@ public function test_execute_callback_post_id_overrides_content() { $result = $method->invoke( $this->ability, $input ); $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 'Post content takes priority.', $result['content'], 'Post content should override provided content' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + // The feature's generate_titles uses the post content, verified by titles being generated. + $this->assertNotEmpty( $result['titles'], 'Should generate titles from post content' ); } /** diff --git a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php index 8befbd18..c493dd4d 100644 --- a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php +++ b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php @@ -71,6 +71,20 @@ public function test_get_ability_slug_returns_correct_format() { $this->assertStringStartsWith( 'ai/', $slug, 'Ability slug should start with ai/' ); } + /** + * Test that get_system_instruction() returns empty string for features without system instruction. + * + * @since 0.1.0 + */ + public function test_get_system_instruction_returns_empty_for_features_without_instruction() { + $feature = new Example_Feature(); + + $system_instruction = $feature->get_system_instruction(); + + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertEquals( '', $system_instruction, 'System instruction should be empty for features without one' ); + } + /** * Test that footer content is added for logged-in users. * diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php index 1a2d7e9e..b0244694 100644 --- a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -70,4 +70,131 @@ public function test_get_ability_slug_returns_correct_format() { $this->assertEquals( 'ai/title-generation', $slug, 'Ability slug should be prefixed with ai/' ); $this->assertStringStartsWith( 'ai/', $slug, 'Ability slug should start with ai/' ); } + + /** + * Test that get_system_instruction() returns the system instruction. + * + * @since 0.1.0 + */ + public function test_get_system_instruction_returns_system_instruction() { + $feature = new Title_Generation(); + + $system_instruction = $feature->get_system_instruction(); + + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); + $this->assertStringContainsString( 'SEO-friendly', $system_instruction, 'System instruction should contain expected content' ); + } + + /** + * Test that generate_titles() returns correct type. + * + * @since 0.1.0 + */ + public function test_generate_titles_returns_correct_type() { + $feature = new Title_Generation(); + + $content = 'This is some test content for title generation.'; + + try { + $result = $feature->generate_titles( $content, 2 ); + } catch ( \Exception $e ) { + // If an exception is thrown (e.g., no models configured), that's acceptable + // for testing purposes. The method should ideally catch this, but for now + // we'll mark the test as skipped if models aren't configured. + $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); + return; + } + + // The result will either be titles or an error depending on whether + // the AI client is available. We'll verify it's the right type. + $this->assertTrue( + is_array( $result ) || is_wp_error( $result ), + 'generate_titles should return array or WP_Error' + ); + + // If we got an array, verify it contains strings. + if ( is_array( $result ) ) { + $this->assertNotEmpty( $result, 'Should return non-empty array when successful' ); + foreach ( $result as $title ) { + $this->assertIsString( $title, 'Each title should be a string' ); + } + } + } + + /** + * Test that generate_titles() uses system instruction. + * + * @since 0.1.0 + */ + public function test_generate_titles_uses_system_instruction() { + $feature = new Title_Generation(); + + $content = 'Test content'; + + try { + $result = $feature->generate_titles( $content ); + } catch ( \Exception $e ) { + // If models aren't configured, skip the test. + $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); + return; + } + + // Verify the method completes without fatal error. + // The actual AI call may fail if client is unavailable, but the method + // should handle it gracefully. + $this->assertTrue( + is_array( $result ) || is_wp_error( $result ), + 'generate_titles should return array or WP_Error' + ); + } + + /** + * Test that generate_titles() passes options correctly. + * + * @since 0.1.0 + */ + public function test_generate_titles_passes_options() { + $feature = new Title_Generation(); + + $content = 'Test content'; + + try { + $result = $feature->generate_titles( $content, 3 ); + } catch ( \Exception $e ) { + // If models aren't configured, skip the test. + $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); + return; + } + + // Verify the method accepts the n parameter. + $this->assertTrue( + is_array( $result ) || is_wp_error( $result ), + 'generate_titles should return array or WP_Error' + ); + } + + /** + * Test that generate_titles() handles API errors gracefully. + * + * @since 0.1.0 + */ + public function test_generate_titles_handles_api_errors() { + $feature = new Title_Generation(); + + $content = 'Test content'; + + try { + $result = $feature->generate_titles( $content ); + } catch ( \Exception $e ) { + // If models aren't configured, skip the test. + $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); + return; + } + + // If AI client is unavailable, should return WP_Error. + if ( is_wp_error( $result ) ) { + $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error on failure' ); + } + } } From e8fa8872e343d46fa19d8072ba3fb984a2fb9cb9 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 14:35:18 -0700 Subject: [PATCH 09/43] Fix lint errors --- includes/API_Request.php | 17 +++++++++-------- includes/Abilities/Title_Generation.php | 2 +- .../Title_Generation/Title_Generation.php | 4 ++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index 5af68fff..28dca221 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -7,9 +7,10 @@ namespace WordPress\AI; +use Throwable; +use WP_Error; use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; -use WP_Error; /** * Handles API requests to various AI services. @@ -59,7 +60,7 @@ public function __construct( string $provider = '', string $model = '' ) { * @param array $options The options to send. * @return array|\WP_Error The result of the request. */ - public function generate_text( $prompt = null, $system_instruction = null, array $options = [] ) { + public function generate_text( $prompt = null, $system_instruction = null, array $options = array() ) { if ( ! $this->is_client_available() ) { return new WP_Error( 'ai_client_not_available', __( 'AI Client is not available', 'ai' ) ); } @@ -83,7 +84,7 @@ public function generate_text( $prompt = null, $system_instruction = null, array * @param array $options The options to send. * @return \WordPress\AiClient\PromptBuilder|\WP_Error The prompt builder or a WP_Error. */ - protected function prompt_builder( $prompt = null, $system_instruction = null, array $options = [] ) { + protected function prompt_builder( $prompt = null, $system_instruction = null, array $options = array() ) { try { $model_config = $this->process_model_config( $options ); $prompt_builder = AiClient::prompt( $prompt ); @@ -105,8 +106,8 @@ protected function prompt_builder( $prompt = null, $system_instruction = null, a } return $prompt_builder; - } catch ( \Exception $e ) { - return new WP_Error( 'ai_client_error', $e->getMessage() ); + } catch ( Throwable $t ) { + return new WP_Error( 'ai_client_error', $t->getMessage() ); } } @@ -123,7 +124,7 @@ protected function get_result( array $response ) { return new WP_Error( 'no_choices', __( 'No choices were returned from the AI provider', 'ai' ) ); } - $results = []; + $results = array(); foreach ( $response as $choice ) { $results[] = $this->sanitize_choice( $choice ); } @@ -149,11 +150,11 @@ protected function sanitize_choice( string $choice ): string { * @since 0.1.0 * * @param array $options The options to add to the model config. - * @return ModelConfig + * @return \WordPress\AiClient\Providers\Models\DTO\ModelConfig */ protected function process_model_config( array $options ): ModelConfig { $schema = ModelConfig::getJsonSchema()['properties']; - $model_config = []; + $model_config = array(); foreach ( $options as $key => $value ) { if ( ! isset( $schema[ $key ] ) ) { diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 9b1e10fd..83c19881 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -130,7 +130,7 @@ protected function execute_callback( $input ) { } // Return the titles in the format the Ability expects. - return [ 'titles' => $result ]; + return array( 'titles' => $result ); } /** diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 4f55884c..bfb538b0 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -7,9 +7,9 @@ namespace WordPress\AI\Features\Title_Generation; +use WordPress\AI\API_Request; use WordPress\AI\Abilities\Title_Generation as Title_Generation_Ability; use WordPress\AI\Abstracts\Abstract_Feature; -use WordPress\AI\API_Request; /** * Title generation feature. @@ -80,7 +80,7 @@ public function generate_titles( string $content, int $n = 1 ) { // Make our request. $request = new API_Request(); $response = $request->generate_text( - '"""'. $content . '"""', + '"""' . $content . '"""', $this->get_system_instruction(), array( 'candidateCount' => (int) $n, From d9b796582b673e0b02ddc230a8a075f46c344116 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 14:59:33 -0700 Subject: [PATCH 10/43] Fix PHPStan errors --- includes/API_Request.php | 14 +++++++------- includes/Abilities/Title_Generation.php | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index 28dca221..a7f6e6d8 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -45,7 +45,7 @@ class API_Request { * @param string $provider The desired AI provider. * @param string $model The desired AI model. */ - public function __construct( string $provider = '', string $model = '' ) { + public function __construct( ?string $provider = null, ?string $model = null ) { $this->provider = $provider ?? null; $this->model = $model ?? null; } @@ -57,8 +57,8 @@ public function __construct( string $provider = '', string $model = '' ) { * * @param string|null $prompt The prompt to send. * @param string|null $system_instruction The system instruction to send. - * @param array $options The options to send. - * @return array|\WP_Error The result of the request. + * @param array $options The options to send. + * @return array|\WP_Error The result of the request. */ public function generate_text( $prompt = null, $system_instruction = null, array $options = array() ) { if ( ! $this->is_client_available() ) { @@ -81,8 +81,8 @@ public function generate_text( $prompt = null, $system_instruction = null, array * * @param string|null $prompt The prompt to send. * @param string|null $system_instruction The system instruction to send. - * @param array $options The options to send. - * @return \WordPress\AiClient\PromptBuilder|\WP_Error The prompt builder or a WP_Error. + * @param array $options The options to send. + * @return \WordPress\AiClient\Builders\PromptBuilder|\WP_Error The prompt builder or a WP_Error. */ protected function prompt_builder( $prompt = null, $system_instruction = null, array $options = array() ) { try { @@ -116,8 +116,8 @@ protected function prompt_builder( $prompt = null, $system_instruction = null, a * * @since 0.1.0 * - * @param array $response The response from the AI SDK Client. - * @return array|\WP_Error + * @param array $response The response from the AI SDK Client. + * @return array|\WP_Error */ protected function get_result( array $response ) { if ( empty( $response ) ) { diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 83c19881..10b79cf5 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -85,7 +85,7 @@ protected function output_schema(): array { * @since 0.1.0 * * @param mixed $input The input arguments to the ability. - * @return mixed|\WP_Error The result of the ability execution, or a WP_Error on failure. + * @return array{titles: array{string}}|\WP_Error The result of the ability execution, or a WP_Error on failure. */ protected function execute_callback( $input ) { // Default arguments. From 9c6f47989cf260475a7386bb4e676b8eaface767 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 15:09:03 -0700 Subject: [PATCH 11/43] More PHPStan fixes --- includes/API_Request.php | 3 ++- includes/Features/Title_Generation/Title_Generation.php | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index a7f6e6d8..1103d231 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -149,7 +149,7 @@ protected function sanitize_choice( string $choice ): string { * * @since 0.1.0 * - * @param array $options The options to add to the model config. + * @param array $options The options to add to the model config. * @return \WordPress\AiClient\Providers\Models\DTO\ModelConfig */ protected function process_model_config( array $options ): ModelConfig { @@ -183,6 +183,7 @@ protected function process_model_config( array $options ): ModelConfig { $model_config[ $key ] = $processed_value; } + // @phpstan-ignore-next-line - fromArray() validates the array shape at runtime. return ModelConfig::fromArray( $model_config ); } diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index bfb538b0..09e245dc 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -72,9 +72,8 @@ public function register_abilities(): void { * @since 0.1.0 * * @param string $content The content to generate a title from. - * @param int|null $post_id The post ID to generate a title from. * @param int $n The number of titles to generate. - * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. + * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ public function generate_titles( string $content, int $n = 1 ) { // Make our request. From 44fbe5996de19d511dca1d0d24c75e9c36995cdf Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 3 Nov 2025 15:10:51 -0700 Subject: [PATCH 12/43] Fix typo --- includes/Abilities/Title_Generation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 10b79cf5..3d6d6de1 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -85,7 +85,7 @@ protected function output_schema(): array { * @since 0.1.0 * * @param mixed $input The input arguments to the ability. - * @return array{titles: array{string}}|\WP_Error The result of the ability execution, or a WP_Error on failure. + * @return array{titles: array}|\WP_Error The result of the ability execution, or a WP_Error on failure. */ protected function execute_callback( $input ) { // Default arguments. From 9930535c11464f4837a48219bd69b38b1e960fad Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 5 Nov 2025 13:00:30 -0700 Subject: [PATCH 13/43] Update our system instructions --- includes/Features/Title_Generation/Title_Generation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 09e245dc..14123ea5 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -24,7 +24,7 @@ class Title_Generation extends Abstract_Feature { * @since 0.1.0 * @var string */ - protected $system_instruction = 'Generate an SEO-friendly title for the provided content, staying within a range of 40 to 60 characters and maintaining the original meaning and context. The content you will be provided is delimited by triple quotes.'; // TODO: tune this prompt. + protected $system_instruction = 'You are an editorial assistant that generates title suggestions for online articles and pages. You will be provided some content and some optional additional context and the goal is to generate a concise, engaging, and accurate title that reflects the content and context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author\'s intent and audience. The title suggestion should be no more than 80 characters; should not contain any markdown, bullets, numbering, or formatting - plain text only; should be distinct in tone or focus; must reflect the actual content and context, not generic clickbait. The content you will be provided is delimited by triple quotes.'; /** * Load feature metadata. From 017641826aa9e99bd5b0eb9108f374bd05d94a50 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 5 Nov 2025 13:00:50 -0700 Subject: [PATCH 14/43] Set our default title suggestions to 3 --- includes/Abilities/Title_Generation.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 3d6d6de1..3e4c81d0 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -50,6 +50,7 @@ protected function input_schema(): array { 'type' => 'integer', 'minimum' => 1, 'maximum' => 10, + 'default' => 3, 'sanitize_callback' => 'absint', 'description' => esc_html__( 'Number of titles to generate', 'ai' ), ), @@ -94,7 +95,7 @@ protected function execute_callback( $input ) { array( 'content' => null, 'post_id' => null, - 'n' => 1, + 'n' => 3, ), ); From 797439f730f05a6bf04e749cf1576fd05497392a Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 5 Nov 2025 13:23:55 -0700 Subject: [PATCH 15/43] Instead of just sending the content, build up additional context to send as well --- includes/Abilities/Title_Generation.php | 64 ++++++++++++++++++- .../Title_Generation/Title_Generation.php | 9 +-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 3e4c81d0..71f564fa 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -99,6 +99,9 @@ protected function execute_callback( $input ) { ), ); + // Setup the context we want to pass to the AI. + $context = 'Content: ' . $args['content'] . "\n"; + // If a post ID is provided, ensure the post exists before using its' content. if ( $args['post_id'] ) { $post = get_post( $args['post_id'] ); @@ -111,7 +114,13 @@ protected function execute_callback( $input ) { ); } - $args['content'] = $post->post_content; + // Default to the passed in content but fallback to the post content otherwise. + if ( ! $args['content'] ) { + $args['content'] = $post->post_content; + $context = 'Content: ' . $args['content'] . "\n"; + } + + $context .= $this->get_context( $args['post_id'] ); } // If we have no content, return an error. @@ -123,7 +132,7 @@ protected function execute_callback( $input ) { } // Generate the titles. - $result = $this->feature->generate_titles( $args['content'], $args['n'] ); + $result = $this->feature->generate_titles( $context, $args['n'] ); // If we have an error, return it. if ( is_wp_error( $result ) ) { @@ -165,4 +174,55 @@ protected function meta(): array { 'show_in_rest' => true, ); } + + /** + * Returns the context for the given post ID. + * + * @since 0.1.0 + * + * @param int $post_id The ID of the post to get the context for. + * @return string The context for the given post ID. + */ + protected function get_context( int $post_id ): string { + $post = get_post( $post_id ); + $context = ''; + + // If the post doesn't exist, return an empty string. + if ( ! $post ) { + return $context; + } + + if ( $post->post_title ) { + $context .= 'Current Title: ' . $post->post_title . "\n"; + } + + if ( $post->post_name ) { + $context .= 'Slug: ' . $post->post_name . "\n"; + } + + $author = get_user_by( 'ID', $post->post_author ); + if ( $author ) { + $context .= 'Author: ' . $author->display_name . "\n"; + } + + if ( $post->post_type ) { + $context .= 'Content Type: ' . $post->post_type . "\n"; + } + + if ( $post->post_excerpt ) { + $context .= 'Excerpt: ' . $post->post_excerpt . "\n"; + } + + $categories = get_the_terms( $post_id, 'category' ); + if ( $categories ) { + $context .= 'Categories: ' . implode( ', ', wp_list_pluck( $categories, 'name' ) ) . "\n"; + } + + $tags = get_the_terms( $post_id, 'post_tag' ); + if ( $tags ) { + $context .= 'Tags: ' . implode( ', ', wp_list_pluck( $tags, 'name' ) ) . "\n"; + } + + return $context; + } } diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 14123ea5..7c9441b1 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -24,7 +24,7 @@ class Title_Generation extends Abstract_Feature { * @since 0.1.0 * @var string */ - protected $system_instruction = 'You are an editorial assistant that generates title suggestions for online articles and pages. You will be provided some content and some optional additional context and the goal is to generate a concise, engaging, and accurate title that reflects the content and context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author\'s intent and audience. The title suggestion should be no more than 80 characters; should not contain any markdown, bullets, numbering, or formatting - plain text only; should be distinct in tone or focus; must reflect the actual content and context, not generic clickbait. The content you will be provided is delimited by triple quotes.'; + protected $system_instruction = 'You are an editorial assistant that generates title suggestions for online articles and pages. You will be provided some context and the goal is to generate a concise, engaging, and accurate title that reflects that context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author\'s intent and audience. The title suggestion should be no more than 80 characters; should not contain any markdown, bullets, numbering, or formatting - plain text only; should be distinct in tone or focus; must reflect the actual content and context, not generic clickbait. The context you will be provided is delimited by triple quotes.'; /** * Load feature metadata. @@ -71,15 +71,16 @@ public function register_abilities(): void { * * @since 0.1.0 * - * @param string $content The content to generate a title from. + * @param string $context The context to generate a title from. * @param int $n The number of titles to generate. * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ - public function generate_titles( string $content, int $n = 1 ) { + public function generate_titles( string $context, int $n = 1 ) { + var_dump( $context ); die; // Make our request. $request = new API_Request(); $response = $request->generate_text( - '"""' . $content . '"""', + '"""' . $context . '"""', $this->get_system_instruction(), array( 'candidateCount' => (int) $n, From 6d0127abd72819830658f5ba1f14189991b04fd5 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 5 Nov 2025 13:38:48 -0700 Subject: [PATCH 16/43] Add helper method to normalize content, basically stripping unwanted text --- composer.json | 5 +- includes/Abilities/Title_Generation.php | 8 ++- .../Title_Generation/Title_Generation.php | 1 - includes/Helpers.php | 56 +++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 includes/Helpers.php diff --git a/composer.json b/composer.json index 2c8f851a..efc4e5fa 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,10 @@ "autoload": { "psr-4": { "WordPress\\AI\\": "includes/" - } + }, + "files": [ + "includes/Helpers.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 71f564fa..ce080eca 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -10,6 +10,8 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; +use function WordPress\AI\normalize_content; + /** * Title generation WordPress Ability. * @@ -100,7 +102,7 @@ protected function execute_callback( $input ) { ); // Setup the context we want to pass to the AI. - $context = 'Content: ' . $args['content'] . "\n"; + $context = 'Content: ' . normalize_content( $args['content'] ?? '' ) . "\n"; // If a post ID is provided, ensure the post exists before using its' content. if ( $args['post_id'] ) { @@ -116,8 +118,8 @@ protected function execute_callback( $input ) { // Default to the passed in content but fallback to the post content otherwise. if ( ! $args['content'] ) { - $args['content'] = $post->post_content; - $context = 'Content: ' . $args['content'] . "\n"; + $args['content'] = apply_filters( 'the_content', $post->post_content ); + $context = 'Content: ' . normalize_content( $args['content'] ) . "\n"; } $context .= $this->get_context( $args['post_id'] ); diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 7c9441b1..af0db9db 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -76,7 +76,6 @@ public function register_abilities(): void { * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ public function generate_titles( string $context, int $n = 1 ) { - var_dump( $context ); die; // Make our request. $request = new API_Request(); $response = $request->generate_text( diff --git a/includes/Helpers.php b/includes/Helpers.php new file mode 100644 index 00000000..c3fc7d47 --- /dev/null +++ b/includes/Helpers.php @@ -0,0 +1,56 @@ +#', "\n\n", $content ); + + // Strip all HTML tags. + $content = wp_strip_all_tags( $content ); + + // Remove unrendered shortcode tags. + $content = preg_replace( '#\[.+\](.+)\[/.+\]#', '$1', $content ); + + /** + * Filters the normalized content to allow for additional cleanup. + * + * @since 0.1.0 + * @hook ai_normalize_content + * + * @param string $content The normalized content. + * + * @return string The filtered normalized content. + */ + $content = apply_filters( 'ai_normalize_content', trim( $content ) ); + + return $content; +} From 6a192ee8d1e2e2871f7be5ad3e5c33a05e4b309f Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 5 Nov 2025 14:14:24 -0700 Subject: [PATCH 17/43] Move our prompt to a separate markdown file to make it easier to manage. Load this file as our prompt if it exists --- includes/Abstracts/Abstract_Feature.php | 51 +++++++++++++++++++ .../Title_Generation/Title_Generation.php | 8 --- .../Title_Generation/system-instruction.md | 12 +++++ 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 includes/Features/Title_Generation/system-instruction.md diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Feature.php index fc3d17c7..d745e072 100644 --- a/includes/Abstracts/Abstract_Feature.php +++ b/includes/Abstracts/Abstract_Feature.php @@ -7,6 +7,7 @@ namespace WordPress\AI\Abstracts; +use ReflectionClass; use WordPress\AI\Contracts\Feature; use WordPress\AI\Exception\Invalid_Feature_Metadata_Exception; @@ -91,6 +92,12 @@ final public function __construct() { $this->id = $metadata['id']; $this->label = $metadata['label']; $this->description = $metadata['description']; + + // Try to load system instruction from file. + $loaded_instruction = $this->load_system_instruction_from_file(); + if ( ! empty( $loaded_instruction ) ) { // phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed + $this->system_instruction = $loaded_instruction; + } } /** @@ -159,6 +166,50 @@ public function get_system_instruction(): string { return $this->system_instruction; } + /** + * Loads system instruction from a markdown file in the feature's directory. + * + * Supports both automatic detection (looks for `system-instruction.md` or `prompt.md`) + * and explicit file paths. Returns empty string if file is not found, maintaining + * backward compatibility with hardcoded system instructions. + * + * @since 0.1.0 + * + * @param string|null $filename Optional. Explicit filename to load. If not provided, + * attempts to load `system-instruction.md` or `prompt.md`. + * @return string The contents of the file, or empty string if file not found. + */ + protected function load_system_instruction_from_file( ?string $filename = null ): string { + // Get the feature's directory using reflection. + $reflection = new ReflectionClass( $this ); + $feature_dir = dirname( $reflection->getFileName() ); + + // If explicit filename provided, use it. + if ( null !== $filename ) { + $file_path = trailingslashit( $feature_dir ) . $filename; + + if ( file_exists( $file_path ) && is_readable( $file_path ) ) { + $content = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown + return false !== $content ? trim( wp_strip_all_tags( $content ) ) : ''; + } + + return ''; + } + + // Automatic detection: first `system-instruction.md`, then `prompt.md`. + $possible_files = array( 'system-instruction.md', 'prompt.md' ); + foreach ( $possible_files as $possible_file ) { + $file_path = trailingslashit( $feature_dir ) . $possible_file; + + if ( file_exists( $file_path ) && is_readable( $file_path ) ) { + $content = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown + return false !== $content ? trim( wp_strip_all_tags( $content ) ) : ''; + } + } + + return ''; + } + /** * Checks if feature is enabled. * diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index af0db9db..2ac3cb82 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -18,14 +18,6 @@ */ class Title_Generation extends Abstract_Feature { - /** - * System instruction the feature uses. - * - * @since 0.1.0 - * @var string - */ - protected $system_instruction = 'You are an editorial assistant that generates title suggestions for online articles and pages. You will be provided some context and the goal is to generate a concise, engaging, and accurate title that reflects that context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author\'s intent and audience. The title suggestion should be no more than 80 characters; should not contain any markdown, bullets, numbering, or formatting - plain text only; should be distinct in tone or focus; must reflect the actual content and context, not generic clickbait. The context you will be provided is delimited by triple quotes.'; - /** * Load feature metadata. * diff --git a/includes/Features/Title_Generation/system-instruction.md b/includes/Features/Title_Generation/system-instruction.md new file mode 100644 index 00000000..89b256b8 --- /dev/null +++ b/includes/Features/Title_Generation/system-instruction.md @@ -0,0 +1,12 @@ +You are an editorial assistant that generates title suggestions for online articles and pages. + +Goal: You will be provided with some context and you should then generate a concise, engaging, and accurate title that reflects that context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author's intent and audience. + +The title suggestion should follow these requirements: + +- Be no more than 80 characters +- Should not contain any markdown, bullets, numbering, or formatting - plain text only +- Should be distinct in tone and focus +- Must reflect the actual content and context, not generic clickbait + +The context you will be provided is delimited by triple quotes. From 55d5ec23f8fdae0517acdfb9cda3ae933b4612d2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 5 Nov 2025 15:48:07 -0700 Subject: [PATCH 18/43] Fix tests --- includes/Abilities/Title_Generation.php | 4 ++-- includes/Abstracts/Abstract_Feature.php | 10 ++++++++-- includes/Helpers.php | 10 +++++----- .../Includes/Abilities/Title_GenerationTest.php | 2 +- .../Features/Title_Generation/Title_GenerationTest.php | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index ce080eca..5ee25fcf 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -216,12 +216,12 @@ protected function get_context( int $post_id ): string { } $categories = get_the_terms( $post_id, 'category' ); - if ( $categories ) { + if ( $categories && ! is_wp_error( $categories ) ) { $context .= 'Categories: ' . implode( ', ', wp_list_pluck( $categories, 'name' ) ) . "\n"; } $tags = get_the_terms( $post_id, 'post_tag' ); - if ( $tags ) { + if ( $tags && ! is_wp_error( $tags ) ) { $context .= 'Tags: ' . implode( ', ', wp_list_pluck( $tags, 'name' ) ) . "\n"; } diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Feature.php index d745e072..8b9ab238 100644 --- a/includes/Abstracts/Abstract_Feature.php +++ b/includes/Abstracts/Abstract_Feature.php @@ -181,8 +181,14 @@ public function get_system_instruction(): string { */ protected function load_system_instruction_from_file( ?string $filename = null ): string { // Get the feature's directory using reflection. - $reflection = new ReflectionClass( $this ); - $feature_dir = dirname( $reflection->getFileName() ); + $reflection = new ReflectionClass( $this ); + $file_name = $reflection->getFileName(); + + if ( ! $file_name ) { + return ''; + } + + $feature_dir = dirname( $file_name ); // If explicit filename provided, use it. if ( null !== $filename ) { diff --git a/includes/Helpers.php b/includes/Helpers.php index c3fc7d47..3f0fec65 100644 --- a/includes/Helpers.php +++ b/includes/Helpers.php @@ -26,16 +26,16 @@ function normalize_content( string $content ): string { * * @return string The filtered Post content. */ - $content = apply_filters( 'ai_pre_normalize_content', $content ); + $content = (string) apply_filters( 'ai_pre_normalize_content', $content ); // Strip HTML entities. $content = preg_replace( '/&#?[a-z0-9]{2,8};/i', '', $content ); // Replace HTML linebreaks with newlines. - $content = preg_replace( '##', "\n\n", $content ); + $content = preg_replace( '##', "\n\n", (string) $content ); // Strip all HTML tags. - $content = wp_strip_all_tags( $content ); + $content = wp_strip_all_tags( (string) $content ); // Remove unrendered shortcode tags. $content = preg_replace( '#\[.+\](.+)\[/.+\]#', '$1', $content ); @@ -50,7 +50,7 @@ function normalize_content( string $content ): string { * * @return string The filtered normalized content. */ - $content = apply_filters( 'ai_normalize_content', trim( $content ) ); + $content = apply_filters( 'ai_normalize_content', $content ); - return $content; + return trim( (string) $content ); } diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index f231f950..d24d6b26 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -283,7 +283,7 @@ public function test_execute_callback_uses_defaults() { $this->assertIsArray( $result, 'Result should be an array' ); $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); $this->assertIsArray( $result['titles'], 'Titles should be an array' ); - $this->assertCount( 1, $result['titles'], 'Should have 1 title by default' ); + $this->assertCount( 3, $result['titles'], 'Should have 3 titles by default' ); } /** diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php index b0244694..a88aba09 100644 --- a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -83,7 +83,7 @@ public function test_get_system_instruction_returns_system_instruction() { $this->assertIsString( $system_instruction, 'System instruction should be a string' ); $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); - $this->assertStringContainsString( 'SEO-friendly', $system_instruction, 'System instruction should contain expected content' ); + $this->assertStringContainsString( 'You are an editorial assistant', $system_instruction, 'System instruction should contain expected content' ); } /** From 50248ab17faa4e585a64402e96c8a8773bc81cf2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 5 Nov 2025 16:02:51 -0700 Subject: [PATCH 19/43] Fix PHPCS error --- includes/Helpers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Helpers.php b/includes/Helpers.php index 3f0fec65..8a9f2119 100644 --- a/includes/Helpers.php +++ b/includes/Helpers.php @@ -50,7 +50,7 @@ function normalize_content( string $content ): string { * * @return string The filtered normalized content. */ - $content = apply_filters( 'ai_normalize_content', $content ); + $content = (string) apply_filters( 'ai_normalize_content', (string) $content ); - return trim( (string) $content ); + return trim( $content ); } From b32ae5c7940bc254022b2b9f7398b9c0129ee1ca Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 6 Nov 2025 09:30:57 -0700 Subject: [PATCH 20/43] Ensure we properly handle thrown exceptions when making the request --- includes/API_Request.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index 1103d231..a9374f76 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -71,7 +71,11 @@ public function generate_text( $prompt = null, $system_instruction = null, array return $prompt_builder; } - return $this->get_result( $prompt_builder->generateTexts() ); + try { + return $this->get_result( $prompt_builder->generateTexts() ); + } catch ( Throwable $t ) { + return new WP_Error( 'ai_client_error', $t->getMessage() ); + } } /** From 09da805c5d576da38fcf4a20b785d6d8c0ccf797 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 6 Nov 2025 09:31:53 -0700 Subject: [PATCH 21/43] Set our context as an array instead of a string for ease of use. Convert that to a string when sending it in a request --- includes/Abilities/Title_Generation.php | 32 +++++++++++-------- .../Title_Generation/Title_Generation.php | 22 +++++++++++-- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 5ee25fcf..6ac60bd0 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -102,7 +102,9 @@ protected function execute_callback( $input ) { ); // Setup the context we want to pass to the AI. - $context = 'Content: ' . normalize_content( $args['content'] ?? '' ) . "\n"; + $context = array( + 'content' => normalize_content( $args['content'] ?? '' ), + ); // If a post ID is provided, ensure the post exists before using its' content. if ( $args['post_id'] ) { @@ -119,10 +121,12 @@ protected function execute_callback( $input ) { // Default to the passed in content but fallback to the post content otherwise. if ( ! $args['content'] ) { $args['content'] = apply_filters( 'the_content', $post->post_content ); - $context = 'Content: ' . normalize_content( $args['content'] ) . "\n"; + $context = array( + 'content' => normalize_content( $args['content'] ), + ); } - $context .= $this->get_context( $args['post_id'] ); + $context = array_merge( $context, $this->get_context( $args['post_id'] ) ); } // If we have no content, return an error. @@ -183,46 +187,46 @@ protected function meta(): array { * @since 0.1.0 * * @param int $post_id The ID of the post to get the context for. - * @return string The context for the given post ID. + * @return array The context for the given post ID. */ - protected function get_context( int $post_id ): string { + protected function get_context( int $post_id ): array { $post = get_post( $post_id ); - $context = ''; + $context = array(); - // If the post doesn't exist, return an empty string. + // If the post doesn't exist, return early. if ( ! $post ) { return $context; } if ( $post->post_title ) { - $context .= 'Current Title: ' . $post->post_title . "\n"; + $context['current_title'] = $post->post_title; } if ( $post->post_name ) { - $context .= 'Slug: ' . $post->post_name . "\n"; + $context['slug'] = $post->post_name; } $author = get_user_by( 'ID', $post->post_author ); if ( $author ) { - $context .= 'Author: ' . $author->display_name . "\n"; + $context['author'] = $author->display_name; } if ( $post->post_type ) { - $context .= 'Content Type: ' . $post->post_type . "\n"; + $context['content_type'] = $post->post_type; } if ( $post->post_excerpt ) { - $context .= 'Excerpt: ' . $post->post_excerpt . "\n"; + $context['excerpt'] = $post->post_excerpt; } $categories = get_the_terms( $post_id, 'category' ); if ( $categories && ! is_wp_error( $categories ) ) { - $context .= 'Categories: ' . implode( ', ', wp_list_pluck( $categories, 'name' ) ) . "\n"; + $context['categories'] = implode( ', ', wp_list_pluck( $categories, 'name' ) ); } $tags = get_the_terms( $post_id, 'post_tag' ); if ( $tags && ! is_wp_error( $tags ) ) { - $context .= 'Tags: ' . implode( ', ', wp_list_pluck( $tags, 'name' ) ) . "\n"; + $context['tags'] = implode( ', ', wp_list_pluck( $tags, 'name' ) ); } return $context; diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 2ac3cb82..819ed4e4 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -63,11 +63,29 @@ public function register_abilities(): void { * * @since 0.1.0 * - * @param string $context The context to generate a title from. + * @param string|array $context The context to generate a title from. * @param int $n The number of titles to generate. * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ - public function generate_titles( string $context, int $n = 1 ) { + public function generate_titles( $context, int $n = 1 ) { + // 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 + ) + ); + } + // Make our request. $request = new API_Request(); $response = $request->generate_text( From 280b5acda041b79491d6dc8729d668dfd40802b7 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 6 Nov 2025 10:11:03 -0700 Subject: [PATCH 22/43] Set our preferred models for each provider to ensure we don't end up using newer models that may not be fully supported --- includes/API_Request.php | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/includes/API_Request.php b/includes/API_Request.php index a9374f76..8d340150 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -37,6 +37,32 @@ class API_Request { */ protected $model = null; + /** + * The preferred models to use. + * + * @since 0.1.0 + * + * @var array + */ + protected $model_preferences = array( + array( + 'anthropic', + 'claude-haiku-4-5', + ), + array( + 'google', + 'gemini-2.5-flash', + ), + array( + 'openai', + 'gpt-4o-mini', + ), + array( + 'openai', + 'gpt-4.1', + ), + ); + /** * Constructor. * @@ -109,6 +135,11 @@ protected function prompt_builder( $prompt = null, $system_instruction = null, a } } + // Set our preferred models if no model is specified. + if ( empty( $this->model ) ) { + $prompt_builder = $prompt_builder->usingModelPreference( ...$this->model_preferences ); + } + return $prompt_builder; } catch ( Throwable $t ) { return new WP_Error( 'ai_client_error', $t->getMessage() ); From 5394a705e44ad0caf7177a5b866300d8462ae931 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 6 Nov 2025 11:08:25 -0700 Subject: [PATCH 23/43] Fix tests --- includes/API_Request.php | 2 +- tests/Integration/Includes/Abilities/Title_GenerationTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index 8d340150..f0035e7b 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -42,7 +42,7 @@ class API_Request { * * @since 0.1.0 * - * @var array + * @var array> */ protected $model_preferences = array( array( diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index d24d6b26..5005e888 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -47,11 +47,11 @@ public function register(): void { * * @since 0.1.0 * - * @param string $content The content to generate a title from. + * @param string|array $content The content to generate a title from. * @param int $n The number of titles to generate. * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ - public function generate_titles( string $content, int $n = 1 ) { + public function generate_titles( $content, int $n = 1 ) { // For testing, return mock titles. $titles = array(); for ( $i = 1; $i <= $n; $i++ ) { From 10a0d95a02c53dd6d85806f5e8104bac8167ed13 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 6 Nov 2025 11:17:58 -0700 Subject: [PATCH 24/43] Fix PHPStan error --- includes/API_Request.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/API_Request.php b/includes/API_Request.php index f0035e7b..ec00af90 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -42,7 +42,7 @@ class API_Request { * * @since 0.1.0 * - * @var array> + * @var array */ protected $model_preferences = array( array( From 70677b933b167b375de795120d78bb5c461b8b64 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 7 Nov 2025 09:31:25 -0700 Subject: [PATCH 25/43] Switch back to trunk for the WP AI Client now that the settings PR has been merged --- composer.json | 2 +- composer.lock | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index efc4e5fa..71a192fc 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "php": ">=7.4", "wordpress/abilities-api": "^0.4.0", "wordpress/mcp-adapter": "dev-trunk", - "wordpress/wp-ai-client": "dev-add/api-credentials-management" + "wordpress/wp-ai-client": "dev-trunk" }, "require-dev": { "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index f381cf0a..0fdd22f0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5530fc4f50dc724598613d2e677d5a5e", + "content-hash": "6a1adadf48ea6cbf55a51e64e9e46de2", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -1127,16 +1127,16 @@ }, { "name": "wordpress/wp-ai-client", - "version": "dev-add/api-credentials-management", + "version": "dev-trunk", "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "0b05a0717edb68402c60fd51ecec3d622e8d8000" + "reference": "bfa9e48872ae20925f96606b02a802f466a94dd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/0b05a0717edb68402c60fd51ecec3d622e8d8000", - "reference": "0b05a0717edb68402c60fd51ecec3d622e8d8000", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/bfa9e48872ae20925f96606b02a802f466a94dd1", + "reference": "bfa9e48872ae20925f96606b02a802f466a94dd1", "shasum": "" }, "require": { @@ -1154,6 +1154,7 @@ "szepeviktor/phpstan-wordpress": "^1.3", "wp-coding-standards/wpcs": "^3.0" }, + "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1196,7 +1197,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-10-29T22:56:39+00:00" + "time": "2025-11-07T16:28:15+00:00" } ], "packages-dev": [ From 5bd5beda7931b4833b012fdc6fccaaa363f5aea4 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 12 Nov 2025 14:26:53 -0700 Subject: [PATCH 26/43] Move the generate_titles method into the ability as we're no longer passing the feature into the ability. Move the get_post_context method into a helper function so other functionality can take advantage of this. Move the system instructions into the ability --- .../Title_Generation.php | 113 ++++++++---------- .../Title_Generation/system-instruction.md | 0 includes/Abstracts/Abstract_Ability.php | 64 ++++++++++ includes/Abstracts/Abstract_Feature.php | 76 ------------ includes/Contracts/Feature.php | 9 -- .../Title_Generation/Title_Generation.php | 50 +------- includes/Helpers.php | 55 +++++++++ 7 files changed, 170 insertions(+), 197 deletions(-) rename includes/Abilities/{ => Title_Generation}/Title_Generation.php (71%) rename includes/{Features => Abilities}/Title_Generation/system-instruction.md (100%) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php similarity index 71% rename from includes/Abilities/Title_Generation.php rename to includes/Abilities/Title_Generation/Title_Generation.php index 336ba50a..36c4a85d 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -7,11 +7,13 @@ declare( strict_types=1 ); -namespace WordPress\AI\Abilities; +namespace WordPress\AI\Abilities\Title_Generation; use WP_Error; +use WordPress\AI\API_Request; use WordPress\AI\Abstracts\Abstract_Ability; +use function WordPress\AI\get_post_context; use function WordPress\AI\normalize_content; /** @@ -21,14 +23,6 @@ */ class Title_Generation extends Abstract_Ability { - /** - * The Feature class that the ability belongs to. - * - * @since 0.1.0 - * @var \WordPress\AI\Features\Title_Generation\Title_Generation - */ - protected $feature; - /** * Returns the input schema of the ability. * @@ -103,11 +97,6 @@ protected function execute_callback( $input ) { ), ); - // Setup the context we want to pass to the AI. - $context = array( - 'content' => normalize_content( $args['content'] ?? '' ), - ); - // If a post ID is provided, ensure the post exists before using its' content. if ( $args['post_id'] ) { $post = get_post( $args['post_id'] ); @@ -120,19 +109,21 @@ protected function execute_callback( $input ) { ); } - // Default to the passed in content but fallback to the post content otherwise. - if ( ! $args['content'] ) { - $args['content'] = apply_filters( 'the_content', $post->post_content ); - $context = array( - 'content' => normalize_content( $args['content'] ), - ); - } + // Get the post context. + $context = get_post_context( $args['post_id'] ); - $context = array_merge( $context, $this->get_context( $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 ( ! $args['content'] ) { + if ( empty( $context['content'] ) ) { return new WP_Error( 'content_not_provided', esc_html__( 'Content is required to generate title suggestions.', 'ai' ) @@ -140,7 +131,7 @@ protected function execute_callback( $input ) { } // Generate the titles. - $result = $this->feature->generate_titles( $context, $args['n'] ); + $result = $this->generate_titles( $context, $args['n'] ); // If we have an error, return it. if ( is_wp_error( $result ) ) { @@ -219,53 +210,49 @@ protected function meta(): array { } /** - * Returns the context for the given post ID. + * Generates title suggestions from the given content. * * @since 0.1.0 * - * @param int $post_id The ID of the post to get the context for. - * @return array The context for the given post ID. + * @param string|array $context The context to generate a title from. + * @param int $n The number of titles to generate. + * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ - protected function get_context( int $post_id ): array { - $post = get_post( $post_id ); - $context = array(); - - // If the post doesn't exist, return early. - if ( ! $post ) { - return $context; - } - - if ( $post->post_title ) { - $context['current_title'] = $post->post_title; - } - - if ( $post->post_name ) { - $context['slug'] = $post->post_name; - } - - $author = get_user_by( 'ID', $post->post_author ); - if ( $author ) { - $context['author'] = $author->display_name; - } - - if ( $post->post_type ) { - $context['content_type'] = $post->post_type; - } - - if ( $post->post_excerpt ) { - $context['excerpt'] = $post->post_excerpt; + protected function generate_titles( $context, int $n = 1 ) { + // 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 + ) + ); } - $categories = get_the_terms( $post_id, 'category' ); - if ( $categories && ! is_wp_error( $categories ) ) { - $context['categories'] = implode( ', ', wp_list_pluck( $categories, 'name' ) ); - } + // Make our request. + $request = new API_Request(); + $response = $request->generate_text( + '"""' . $context . '"""', + $this->get_system_instruction(), + array( + 'candidateCount' => (int) $n, + 'temperature' => 0.7, + ) + ); - $tags = get_the_terms( $post_id, 'post_tag' ); - if ( $tags && ! is_wp_error( $tags ) ) { - $context['tags'] = implode( ', ', wp_list_pluck( $tags, 'name' ) ); + // If we have an error, return it. + if ( is_wp_error( $response ) ) { + return $response; } - return $context; + return $response; } } diff --git a/includes/Features/Title_Generation/system-instruction.md b/includes/Abilities/Title_Generation/system-instruction.md similarity index 100% rename from includes/Features/Title_Generation/system-instruction.md rename to includes/Abilities/Title_Generation/system-instruction.md diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index 1d53f76a..ca42d35c 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -9,6 +9,7 @@ namespace WordPress\AI\Abstracts; +use ReflectionClass; use WP_Ability; /** @@ -99,4 +100,67 @@ abstract protected function permission_callback( $input ); * @return array The meta of the ability. */ abstract protected function meta(): array; + + /** + * Gets the system instruction for the feature. + * + * @since 0.1.0 + * + * @param string|null $filename Optional. Explicit filename to load. If not provided, + * attempts to load `system-instruction.md` or `prompt.md`. + * @return string The system instruction for the feature. + */ + public function get_system_instruction( ?string $filename = null ): string { + return $this->load_system_instruction_from_file( $filename ); + } + + /** + * Loads system instruction from a markdown file in the feature's directory. + * + * Supports both automatic detection (looks for `system-instruction.md` or `prompt.md`) + * and explicit file paths. Returns empty string if file is not found, maintaining + * backward compatibility with hardcoded system instructions. + * + * @since 0.1.0 + * + * @param string|null $filename Optional. Explicit filename to load. If not provided, + * attempts to load `system-instruction.md` or `prompt.md`. + * @return string The contents of the file, or empty string if file not found. + */ + protected function load_system_instruction_from_file( ?string $filename = null ): string { + // Get the feature's directory using reflection. + $reflection = new ReflectionClass( $this ); + $file_name = $reflection->getFileName(); + + if ( ! $file_name ) { + return ''; + } + + $feature_dir = dirname( $file_name ); + + // If explicit filename provided, use it. + if ( null !== $filename ) { + $file_path = trailingslashit( $feature_dir ) . $filename; + + if ( file_exists( $file_path ) && is_readable( $file_path ) ) { + $content = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown + return false !== $content ? trim( wp_strip_all_tags( $content ) ) : ''; + } + + return ''; + } + + // Automatic detection: first `system-instruction.md`, then `prompt.md`. + $possible_files = array( 'system-instruction.md', 'prompt.md' ); + foreach ( $possible_files as $possible_file ) { + $file_path = trailingslashit( $feature_dir ) . $possible_file; + + if ( file_exists( $file_path ) && is_readable( $file_path ) ) { + $content = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown + return false !== $content ? trim( wp_strip_all_tags( $content ) ) : ''; + } + } + + return ''; + } } diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Feature.php index a209fa78..06eba4ad 100644 --- a/includes/Abstracts/Abstract_Feature.php +++ b/includes/Abstracts/Abstract_Feature.php @@ -7,7 +7,6 @@ namespace WordPress\AI\Abstracts; -use ReflectionClass; use WordPress\AI\Contracts\Feature; use WordPress\AI\Exception\Invalid_Feature_Metadata_Exception; @@ -51,14 +50,6 @@ abstract class Abstract_Feature implements Feature { */ private $enabled = true; - /** - * System instruction to send to the LLM. - * - * @since 0.1.0 - * @var string - */ - protected $system_instruction = ''; - /** * Constructor. * @@ -92,12 +83,6 @@ final public function __construct() { $this->id = $metadata['id']; $this->label = $metadata['label']; $this->description = $metadata['description']; - - // Try to load system instruction from file. - $loaded_instruction = $this->load_system_instruction_from_file(); - if ( ! empty( $loaded_instruction ) ) { // phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed - $this->system_instruction = $loaded_instruction; - } } /** @@ -144,67 +129,6 @@ public function get_description(): string { return $this->description; } - /** - * Gets the system instruction for the feature. - * - * @since 0.1.0 - * - * @return string The system instruction for the feature. - */ - public function get_system_instruction(): string { - return $this->system_instruction; - } - - /** - * Loads system instruction from a markdown file in the feature's directory. - * - * Supports both automatic detection (looks for `system-instruction.md` or `prompt.md`) - * and explicit file paths. Returns empty string if file is not found, maintaining - * backward compatibility with hardcoded system instructions. - * - * @since 0.1.0 - * - * @param string|null $filename Optional. Explicit filename to load. If not provided, - * attempts to load `system-instruction.md` or `prompt.md`. - * @return string The contents of the file, or empty string if file not found. - */ - protected function load_system_instruction_from_file( ?string $filename = null ): string { - // Get the feature's directory using reflection. - $reflection = new ReflectionClass( $this ); - $file_name = $reflection->getFileName(); - - if ( ! $file_name ) { - return ''; - } - - $feature_dir = dirname( $file_name ); - - // If explicit filename provided, use it. - if ( null !== $filename ) { - $file_path = trailingslashit( $feature_dir ) . $filename; - - if ( file_exists( $file_path ) && is_readable( $file_path ) ) { - $content = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown - return false !== $content ? trim( wp_strip_all_tags( $content ) ) : ''; - } - - return ''; - } - - // Automatic detection: first `system-instruction.md`, then `prompt.md`. - $possible_files = array( 'system-instruction.md', 'prompt.md' ); - foreach ( $possible_files as $possible_file ) { - $file_path = trailingslashit( $feature_dir ) . $possible_file; - - if ( file_exists( $file_path ) && is_readable( $file_path ) ) { - $content = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown - return false !== $content ? trim( wp_strip_all_tags( $content ) ) : ''; - } - } - - return ''; - } - /** * Checks if feature is enabled. * diff --git a/includes/Contracts/Feature.php b/includes/Contracts/Feature.php index da5ed2f3..09ea9e0c 100644 --- a/includes/Contracts/Feature.php +++ b/includes/Contracts/Feature.php @@ -48,15 +48,6 @@ public function get_label(): string; */ public function get_description(): string; - /** - * Gets the system instruction for the feature. - * - * @since 0.1.0 - * - * @return string The system instruction for the feature. - */ - public function get_system_instruction(): string; - /** * Registers the feature's hooks and functionality. * diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 8d453a45..80da628c 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -9,8 +9,7 @@ namespace WordPress\AI\Features\Title_Generation; -use WordPress\AI\API_Request; -use WordPress\AI\Abilities\Title_Generation as Title_Generation_Ability; +use WordPress\AI\Abilities\Title_Generation\Title_Generation as Title_Generation_Ability; use WordPress\AI\Abstracts\Abstract_Feature; /** @@ -59,51 +58,4 @@ public function register_abilities(): void { ), ); } - - /** - * Generates title suggestions from the given content. - * - * @since 0.1.0 - * - * @param string|array $context The context to generate a title from. - * @param int $n The number of titles to generate. - * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. - */ - public function generate_titles( $context, int $n = 1 ) { - // 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 - ) - ); - } - - // Make our request. - $request = new API_Request(); - $response = $request->generate_text( - '"""' . $context . '"""', - $this->get_system_instruction(), - array( - 'candidateCount' => (int) $n, - 'temperature' => 0.7, - ) - ); - - // If we have an error, return it. - if ( is_wp_error( $response ) ) { - return $response; - } - - return $response; - } } diff --git a/includes/Helpers.php b/includes/Helpers.php index 8a9f2119..905a91ed 100644 --- a/includes/Helpers.php +++ b/includes/Helpers.php @@ -54,3 +54,58 @@ function normalize_content( string $content ): string { return trim( $content ); } + +/** + * Returns the context for the given post ID. + * + * @since 0.1.0 + * + * @param int $post_id The ID of the post to get the context for. + * @return array The context for the given post ID. + */ +function get_post_context( int $post_id ): array { + $post = get_post( $post_id ); + $context = array(); + + // If the post doesn't exist, return early. + if ( ! $post ) { + return $context; + } + + if ( $post->post_content ) { + $context['content'] = normalize_content( (string) apply_filters( 'the_content', $post->post_content ) ); + } + + if ( $post->post_title ) { + $context['current_title'] = $post->post_title; + } + + if ( $post->post_name ) { + $context['slug'] = $post->post_name; + } + + $author = get_user_by( 'ID', $post->post_author ); + if ( $author ) { + $context['author'] = $author->display_name; + } + + if ( $post->post_type ) { + $context['content_type'] = $post->post_type; + } + + if ( $post->post_excerpt ) { + $context['excerpt'] = $post->post_excerpt; + } + + $categories = get_the_terms( $post_id, 'category' ); + if ( $categories && ! is_wp_error( $categories ) ) { + $context['categories'] = implode( ', ', wp_list_pluck( $categories, 'name' ) ); + } + + $tags = get_the_terms( $post_id, 'post_tag' ); + if ( $tags && ! is_wp_error( $tags ) ) { + $context['tags'] = implode( ', ', wp_list_pluck( $tags, 'name' ) ); + } + + return $context; +} From cdc6b73d0bf4ac18ddae0acd7063ea3961fa54ad Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 12 Nov 2025 14:38:39 -0700 Subject: [PATCH 27/43] Update tests with new changes --- .../Abilities/Title_GenerationTest.php | 50 ++++++- .../Abstracts/Abstract_AbilityTest.php | 23 ++++ .../Example_Feature/Example_FeatureTest.php | 13 -- .../Title_Generation/Title_GenerationTest.php | 126 ------------------ 4 files changed, 66 insertions(+), 146 deletions(-) diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index 35210c65..3af8c8fd 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -7,7 +7,7 @@ namespace WordPress\AI\Tests\Integration\Includes\Abilities; -use WordPress\AI\Abilities\Title_Generation; +use WordPress\AI\Abilities\Title_Generation\Title_Generation; use WordPress\AI\Abstracts\Abstract_Feature; use WP_Error; use WP_UnitTestCase; @@ -43,15 +43,15 @@ public function register(): void { } /** - * Generates title suggestions from the given content. + * Generates title suggestions from the given context. * * @since 0.1.0 * - * @param string|array $content The content to generate a title from. - * @param int $n The number of titles to generate. + * @param string|array $context The context to generate a title from. + * @param int $n The number of titles to generate. * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ - public function generate_titles( $content, int $n = 1 ) { + public function generate_titles( $context, int $n = 1 ) { // For testing, return mock titles. $titles = array(); for ( $i = 1; $i <= $n; $i++ ) { @@ -179,6 +179,19 @@ public function test_output_schema_returns_expected_structure() { $this->assertEquals( 'string', $schema['properties']['titles']['items']['type'], 'Title items should be string type' ); } + /** + * Test that get_system_instruction() returns the system instruction. + * + * @since 0.1.0 + */ + public function test_get_system_instruction_returns_system_instruction() { + $system_instruction = $this->ability->get_system_instruction(); + + // System instruction may be empty if file doesn't exist, or contain content if it does. + // We just verify it returns a string. + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + } + /** * Test that execute_callback() handles content parameter correctly. * @@ -195,11 +208,16 @@ public function test_execute_callback_with_content() { ); $result = $method->invoke( $this->ability, $input ); + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + $this->assertIsArray( $result, 'Result should be an array' ); $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); $this->assertIsArray( $result['titles'], 'Titles should be an array' ); $this->assertCount( 3, $result['titles'], 'Should have 3 titles' ); - $this->assertEquals( 'Generated Title 1', $result['titles'][0], 'First title should match' ); } /** @@ -226,6 +244,12 @@ public function test_execute_callback_with_post_id() { ); $result = $method->invoke( $this->ability, $input ); + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + $this->assertIsArray( $result, 'Result should be an array' ); $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); $this->assertIsArray( $result['titles'], 'Titles should be an array' ); @@ -283,10 +307,16 @@ public function test_execute_callback_uses_defaults() { ); $result = $method->invoke( $this->ability, $input ); + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + $this->assertIsArray( $result, 'Result should be an array' ); $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); $this->assertIsArray( $result['titles'], 'Titles should be an array' ); - $this->assertCount( 3, $result['titles'], 'Should have 3 titles by default' ); + $this->assertCount( 1, $result['titles'], 'Should have 1 title by default' ); } /** @@ -313,6 +343,12 @@ public function test_execute_callback_post_id_overrides_content() { ); $result = $method->invoke( $this->ability, $input ); + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + $this->assertIsArray( $result, 'Result should be an array' ); $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); $this->assertIsArray( $result['titles'], 'Titles should be an array' ); diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php index 913d993f..3b7b0f17 100644 --- a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -240,5 +240,28 @@ public function test_constructor_requires_label() { // Attempting to construct without a label should fail because. new Test_Ability( 'test-ability', array() ); } + + /** + * Test that get_system_instruction() returns empty string when no system instruction file exists. + * + * @since 0.1.0 + */ + public function test_get_system_instruction_returns_empty_when_no_file() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + 'feature' => $feature, + ) + ); + + $system_instruction = $ability->get_system_instruction(); + + // Test ability doesn't have a system instruction file, so should return empty string. + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertEquals( '', $system_instruction, 'System instruction should be empty when no file exists' ); + } } diff --git a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php index 7e1d7c87..913583ab 100644 --- a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php +++ b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php @@ -57,19 +57,6 @@ public function test_feature_registration() { $this->assertTrue( $feature->is_enabled() ); } - /** - * Test that get_system_instruction() returns empty string for features without system instruction. - * - * @since 0.1.0 - */ - public function test_get_system_instruction_returns_empty_for_features_without_instruction() { - $feature = new Example_Feature(); - - $system_instruction = $feature->get_system_instruction(); - - $this->assertIsString( $system_instruction, 'System instruction should be a string' ); - $this->assertEquals( '', $system_instruction, 'System instruction should be empty for features without one' ); - } /** * Test that footer content is added for logged-in users. diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php index 8b9450ed..421ce3a4 100644 --- a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -57,130 +57,4 @@ public function test_feature_registration() { $this->assertTrue( $feature->is_enabled() ); } - /** - * Test that get_system_instruction() returns the system instruction. - * - * @since 0.1.0 - */ - public function test_get_system_instruction_returns_system_instruction() { - $feature = new Title_Generation(); - - $system_instruction = $feature->get_system_instruction(); - - $this->assertIsString( $system_instruction, 'System instruction should be a string' ); - $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); - $this->assertStringContainsString( 'You are an editorial assistant', $system_instruction, 'System instruction should contain expected content' ); - } - - /** - * Test that generate_titles() returns correct type. - * - * @since 0.1.0 - */ - public function test_generate_titles_returns_correct_type() { - $feature = new Title_Generation(); - - $content = 'This is some test content for title generation.'; - - try { - $result = $feature->generate_titles( $content, 2 ); - } catch ( \Exception $e ) { - // If an exception is thrown (e.g., no models configured), that's acceptable - // for testing purposes. The method should ideally catch this, but for now - // we'll mark the test as skipped if models aren't configured. - $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); - return; - } - - // The result will either be titles or an error depending on whether - // the AI client is available. We'll verify it's the right type. - $this->assertTrue( - is_array( $result ) || is_wp_error( $result ), - 'generate_titles should return array or WP_Error' - ); - - // If we got an array, verify it contains strings. - if ( is_array( $result ) ) { - $this->assertNotEmpty( $result, 'Should return non-empty array when successful' ); - foreach ( $result as $title ) { - $this->assertIsString( $title, 'Each title should be a string' ); - } - } - } - - /** - * Test that generate_titles() uses system instruction. - * - * @since 0.1.0 - */ - public function test_generate_titles_uses_system_instruction() { - $feature = new Title_Generation(); - - $content = 'Test content'; - - try { - $result = $feature->generate_titles( $content ); - } catch ( \Exception $e ) { - // If models aren't configured, skip the test. - $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); - return; - } - - // Verify the method completes without fatal error. - // The actual AI call may fail if client is unavailable, but the method - // should handle it gracefully. - $this->assertTrue( - is_array( $result ) || is_wp_error( $result ), - 'generate_titles should return array or WP_Error' - ); - } - - /** - * Test that generate_titles() passes options correctly. - * - * @since 0.1.0 - */ - public function test_generate_titles_passes_options() { - $feature = new Title_Generation(); - - $content = 'Test content'; - - try { - $result = $feature->generate_titles( $content, 3 ); - } catch ( \Exception $e ) { - // If models aren't configured, skip the test. - $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); - return; - } - - // Verify the method accepts the n parameter. - $this->assertTrue( - is_array( $result ) || is_wp_error( $result ), - 'generate_titles should return array or WP_Error' - ); - } - - /** - * Test that generate_titles() handles API errors gracefully. - * - * @since 0.1.0 - */ - public function test_generate_titles_handles_api_errors() { - $feature = new Title_Generation(); - - $content = 'Test content'; - - try { - $result = $feature->generate_titles( $content ); - } catch ( \Exception $e ) { - // If models aren't configured, skip the test. - $this->markTestSkipped( 'AI models not configured in test environment: ' . $e->getMessage() ); - return; - } - - // If AI client is unavailable, should return WP_Error. - if ( is_wp_error( $result ) ) { - $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error on failure' ); - } - } } From 256970ff15e4fe4834da0b391ccce2d14a0ea0cb Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 12 Nov 2025 14:58:17 -0700 Subject: [PATCH 28/43] Pull in latest WP AI Client code and start using more of their classes --- composer.lock | 100 ++++++++++++++++++++++++++++++++++++--- includes/API_Request.php | 17 ++++--- includes/Helpers.php | 2 + includes/bootstrap.php | 6 +-- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/composer.lock b/composer.lock index 0fdd22f0..18db7666 100644 --- a/composer.lock +++ b/composer.lock @@ -396,6 +396,84 @@ ], "time": "2025-08-23T21:21:41+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -1131,16 +1209,17 @@ "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "bfa9e48872ae20925f96606b02a802f466a94dd1" + "reference": "d5a025153bfe027de385d8e526c7d2c967f7e7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/bfa9e48872ae20925f96606b02a802f466a94dd1", - "reference": "bfa9e48872ae20925f96606b02a802f466a94dd1", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/d5a025153bfe027de385d8e526c7d2c967f7e7c7", + "reference": "d5a025153bfe027de385d8e526c7d2c967f7e7c7", "shasum": "" }, "require": { "ext-json": "*", + "nyholm/psr7": "^1.5", "php": ">=7.4", "wordpress/php-ai-client": "^0.2" }, @@ -1151,8 +1230,10 @@ "phpstan/phpstan": "^1.10 | ^2.1", "slevomat/coding-standard": "^8.0", "squizlabs/php_codesniffer": "^3.7", - "szepeviktor/phpstan-wordpress": "^1.3", - "wp-coding-standards/wpcs": "^3.0" + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-coding-standards/wpcs": "^3.0", + "wp-phpunit/wp-phpunit": "^6.8", + "yoast/phpunit-polyfills": "^4.0" }, "default-branch": true, "type": "library", @@ -1161,6 +1242,11 @@ "WordPress\\AI_Client\\": "includes/" } }, + "autoload-dev": { + "psr-4": { + "WordPress\\AI_Client\\PHPUnit\\Includes\\": "tests/phpunit/includes" + } + }, "scripts": { "lint": [ "@phpcs", @@ -1173,7 +1259,7 @@ "phpcbf" ], "phpstan": [ - "phpstan analyze --memory-limit=1024M" + "phpstan analyze --memory-limit=512M" ] }, "license": [ @@ -1197,7 +1283,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-11-07T16:28:15+00:00" + "time": "2025-11-12T15:36:29+00:00" } ], "packages-dev": [ diff --git a/includes/API_Request.php b/includes/API_Request.php index ec00af90..901b7643 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -5,10 +5,13 @@ * @package WordPress\AI */ +declare( strict_types=1 ); + namespace WordPress\AI; use Throwable; use WP_Error; +use WordPress\AI_Client\AI_Client; use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; @@ -26,7 +29,7 @@ class API_Request { * * @var string|null */ - protected $provider = null; + protected ?string $provider = null; /** * The desired AI model. @@ -35,7 +38,7 @@ class API_Request { * * @var string|null */ - protected $model = null; + protected ?string $model = null; /** * The preferred models to use. @@ -44,7 +47,7 @@ class API_Request { * * @var array */ - protected $model_preferences = array( + protected array $model_preferences = array( array( 'anthropic', 'claude-haiku-4-5', @@ -112,12 +115,12 @@ public function generate_text( $prompt = null, $system_instruction = null, array * @param string|null $prompt The prompt to send. * @param string|null $system_instruction The system instruction to send. * @param array $options The options to send. - * @return \WordPress\AiClient\Builders\PromptBuilder|\WP_Error The prompt builder or a WP_Error. + * @return \WordPress\AI_Client\Builders\Prompt_Builder|\WP_Error The prompt builder or a WP_Error. */ protected function prompt_builder( $prompt = null, $system_instruction = null, array $options = array() ) { try { $model_config = $this->process_model_config( $options ); - $prompt_builder = AiClient::prompt( $prompt ); + $prompt_builder = AI_Client::prompt( $prompt ); $prompt_builder = $prompt_builder->usingModelConfig( $model_config ); if ( ! empty( $system_instruction ) ) { @@ -187,7 +190,7 @@ protected function sanitize_choice( string $choice ): string { * @param array $options The options to add to the model config. * @return \WordPress\AiClient\Providers\Models\DTO\ModelConfig */ - protected function process_model_config( array $options ): ModelConfig { + protected function process_model_config( array $options = array() ): ModelConfig { $schema = ModelConfig::getJsonSchema()['properties']; $model_config = array(); @@ -230,6 +233,6 @@ protected function process_model_config( array $options ): ModelConfig { * @return bool True if the client is available, false otherwise. */ protected function is_client_available(): bool { - return class_exists( AiClient::class ); + return class_exists( AI_Client::class ); } } diff --git a/includes/Helpers.php b/includes/Helpers.php index 905a91ed..2ed48596 100644 --- a/includes/Helpers.php +++ b/includes/Helpers.php @@ -5,6 +5,8 @@ * @package WordPress\AI */ +declare( strict_types=1 ); + namespace WordPress\AI; /** diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 85439386..c2ee014c 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -9,7 +9,7 @@ namespace WordPress\AI; -use WordPress\AI_Client\API_Credentials\API_Credentials_Manager; +use WordPress\AI_Client\AI_Client; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { @@ -175,8 +175,8 @@ function initialize_features(): void { $loader->register_default_features(); $loader->initialize_features(); - $api_credentials_manager = new API_Credentials_Manager(); - $api_credentials_manager->initialize(); + // Initialize the WP AI Client. + AI_Client::init(); add_action( 'wp_abilities_api_categories_init', From 8355a89ab7122f194617fea21c95f833f2885a73 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 08:59:56 -0700 Subject: [PATCH 29/43] Pull in latest WP AI Client code and fully switch to using their prompt builder --- composer.lock | 8 ++++---- includes/API_Request.php | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/composer.lock b/composer.lock index 18db7666..40415a2a 100644 --- a/composer.lock +++ b/composer.lock @@ -1209,12 +1209,12 @@ "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "d5a025153bfe027de385d8e526c7d2c967f7e7c7" + "reference": "a41ef6075c1cbb56dfc380fe5c13a347b265d193" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/d5a025153bfe027de385d8e526c7d2c967f7e7c7", - "reference": "d5a025153bfe027de385d8e526c7d2c967f7e7c7", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/a41ef6075c1cbb56dfc380fe5c13a347b265d193", + "reference": "a41ef6075c1cbb56dfc380fe5c13a347b265d193", "shasum": "" }, "require": { @@ -1283,7 +1283,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-11-12T15:36:29+00:00" + "time": "2025-11-13T00:12:21+00:00" } ], "packages-dev": [ diff --git a/includes/API_Request.php b/includes/API_Request.php index 901b7643..d6275f9f 100644 --- a/includes/API_Request.php +++ b/includes/API_Request.php @@ -101,7 +101,7 @@ public function generate_text( $prompt = null, $system_instruction = null, array } try { - return $this->get_result( $prompt_builder->generateTexts() ); + return $this->get_result( $prompt_builder->generate_texts() ); } catch ( Throwable $t ) { return new WP_Error( 'ai_client_error', $t->getMessage() ); } @@ -121,26 +121,26 @@ protected function prompt_builder( $prompt = null, $system_instruction = null, a try { $model_config = $this->process_model_config( $options ); $prompt_builder = AI_Client::prompt( $prompt ); - $prompt_builder = $prompt_builder->usingModelConfig( $model_config ); + $prompt_builder = $prompt_builder->using_model_config( $model_config ); if ( ! empty( $system_instruction ) ) { - $prompt_builder = $prompt_builder->usingSystemInstruction( $system_instruction ); + $prompt_builder = $prompt_builder->using_system_instruction( $system_instruction ); } if ( ! empty( $this->provider ) ) { - $prompt_builder = $prompt_builder->usingProvider( $this->provider ); + $prompt_builder = $prompt_builder->using_provider( $this->provider ); // Set the model. if ( ! empty( $this->model ) ) { $registry = AiClient::defaultRegistry(); $provider_class_name = $registry->getProviderClassName( $this->provider ); - $prompt_builder = $prompt_builder->usingModel( $provider_class_name::model( $this->model ) ); + $prompt_builder = $prompt_builder->using_model( $provider_class_name::model( $this->model ) ); } } // Set our preferred models if no model is specified. if ( empty( $this->model ) ) { - $prompt_builder = $prompt_builder->usingModelPreference( ...$this->model_preferences ); + $prompt_builder = $prompt_builder->using_model_preference( ...$this->model_preferences ); } return $prompt_builder; From 85503ebfc1972a992e9b29946ad049c3ff543585 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 13:31:38 -0700 Subject: [PATCH 30/43] Store our system instructions in PHP files. Update our helper that gets those instructions to account for this --- ...-instruction.md => system-instruction.php} | 16 ++++++-- includes/Abstracts/Abstract_Ability.php | 38 +++++++++++++------ 2 files changed, 39 insertions(+), 15 deletions(-) rename includes/Abilities/Title_Generation/{system-instruction.md => system-instruction.php} (54%) diff --git a/includes/Abilities/Title_Generation/system-instruction.md b/includes/Abilities/Title_Generation/system-instruction.php similarity index 54% rename from includes/Abilities/Title_Generation/system-instruction.md rename to includes/Abilities/Title_Generation/system-instruction.php index 89b256b8..c5a0593a 100644 --- a/includes/Abilities/Title_Generation/system-instruction.md +++ b/includes/Abilities/Title_Generation/system-instruction.php @@ -1,6 +1,16 @@ -You are an editorial assistant that generates title suggestions for online articles and pages. + Date: Thu, 13 Nov 2025 13:42:17 -0700 Subject: [PATCH 31/43] Remove guzzle as a dependency as that's coming in from the WP AI Client now. Remove line changes in test files that weren't intended --- composer.json | 1 - composer.lock | 438 +----------------- .../Example_Feature/Example_FeatureTest.php | 1 - .../Title_Generation/Title_GenerationTest.php | 1 - 4 files changed, 1 insertion(+), 440 deletions(-) diff --git a/composer.json b/composer.json index 71a192fc..c49beb21 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,6 @@ "require": { "automattic/jetpack-autoloader": "^5.0", "ext-json": "*", - "guzzlehttp/guzzle": "^7.10", "php": ">=7.4", "wordpress/abilities-api": "^0.4.0", "wordpress/mcp-adapter": "dev-trunk", diff --git a/composer.lock b/composer.lock index 40415a2a..439c1c09 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6a1adadf48ea6cbf55a51e64e9e46de2", + "content-hash": "f275018de0bc539ae98c534ab9e40c5a", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -71,331 +71,6 @@ }, "time": "2025-10-06T10:32:52+00:00" }, - { - "name": "guzzlehttp/guzzle", - "version": "7.10.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "provide": { - "psr/http-client-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" - ], - "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" - } - ], - "time": "2025-08-23T22:36:01+00:00" - }, - { - "name": "guzzlehttp/promises", - "version": "2.3.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" - } - ], - "time": "2025-08-22T14:34:08+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "2.8.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" - } - ], - "time": "2025-08-23T21:21:41+00:00" - }, { "name": "nyholm/psr7", "version": "1.8.2", @@ -877,117 +552,6 @@ }, "time": "2023-04-04T09:54:51+00:00" }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v2.5.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", - "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "2.5-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:11:13+00:00" - }, { "name": "wordpress/abilities-api", "version": "v0.4.0", diff --git a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php index 913583ab..4d0db0cb 100644 --- a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php +++ b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php @@ -57,7 +57,6 @@ public function test_feature_registration() { $this->assertTrue( $feature->is_enabled() ); } - /** * Test that footer content is added for logged-in users. * diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php index 421ce3a4..7e40f396 100644 --- a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -56,5 +56,4 @@ public function test_feature_registration() { $this->assertEquals( 'Title Generation', $feature->get_label() ); $this->assertTrue( $feature->is_enabled() ); } - } From 4b7b78d36c5546aa096d7187cab4e039ce5dd89a Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 13:50:05 -0700 Subject: [PATCH 32/43] Remove unneeded comment. Add TODO comment --- .../Abilities/Title_Generation/system-instruction.php | 3 --- includes/Helpers.php | 8 ++++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/includes/Abilities/Title_Generation/system-instruction.php b/includes/Abilities/Title_Generation/system-instruction.php index c5a0593a..6ceb29c0 100644 --- a/includes/Abilities/Title_Generation/system-instruction.php +++ b/includes/Abilities/Title_Generation/system-instruction.php @@ -2,9 +2,6 @@ /** * System instruction for the Title Generation ability. * - * This file returns the system instruction as a string, which allows it to be - * cached by PHP's opcache for better performance. - * * @package WordPress\AI\Abilities\Title_Generation */ diff --git a/includes/Helpers.php b/includes/Helpers.php index 2ed48596..cb056d14 100644 --- a/includes/Helpers.php +++ b/includes/Helpers.php @@ -74,6 +74,14 @@ function get_post_context( int $post_id ): array { return $context; } + /** + * TODO: Might be interesting to add simple Abilities for the following, + * just as a way to demonstrate a different approach to registering Abilities, + * how to call Abilities via PHP and how multiple Abilities can be used together. + * + * Example: Get post content Ability; get post author Ability; get post terms Ability. + */ + if ( $post->post_content ) { $context['content'] = normalize_content( (string) apply_filters( 'the_content', $post->post_content ) ); } From eb226ba437c8d6934150b243a91a3ad8c9f723dd Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 14:44:35 -0700 Subject: [PATCH 33/43] Use Nowdoc formatting in our instructions --- .../Abilities/Title_Generation/system-instruction.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/includes/Abilities/Title_Generation/system-instruction.php b/includes/Abilities/Title_Generation/system-instruction.php index 6ceb29c0..400ce506 100644 --- a/includes/Abilities/Title_Generation/system-instruction.php +++ b/includes/Abilities/Title_Generation/system-instruction.php @@ -5,9 +5,10 @@ * @package WordPress\AI\Abilities\Title_Generation */ -return 'You are an editorial assistant that generates title suggestions for online articles and pages. +return <<<'INSTRUCTION' +You are an editorial assistant that generates title suggestions for online articles and pages. -Goal: You will be provided with some context and you should then generate a concise, engaging, and accurate title that reflects that context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author\'s intent and audience. +Goal: You will be provided with some context and you should then generate a concise, engaging, and accurate title that reflects that context. This title should be optimized for clarity, engagement, and SEO - while maintaining an appropriate tone for the author's intent and audience. The title suggestion should follow these requirements: @@ -16,4 +17,5 @@ - Should be distinct in tone and focus - Must reflect the actual content and context, not generic clickbait -The context you will be provided is delimited by triple quotes.'; +The context you will be provided is delimited by triple quotes. +INSTRUCTION; From 556d6dba36fd0b665e47e11bce4efcdaa0b686db Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 14:51:19 -0700 Subject: [PATCH 34/43] Rename n to candidates --- .../Title_Generation/Title_Generation.php | 20 +++++++++---------- .../Title_Generation/system-instruction.php | 1 + .../Abilities/Title_GenerationTest.php | 8 ++++---- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index 36c4a85d..11ce201d 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -34,17 +34,17 @@ protected function input_schema(): array { return array( 'type' => 'object', 'properties' => array( - 'content' => array( + 'content' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'description' => esc_html__( 'Content to generate title suggestions for.', 'ai' ), ), - 'post_id' => array( + 'post_id' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'description' => esc_html__( 'Content from this post will be used to generate title suggestions. This overrides the content parameter if both are provided.', 'ai' ), ), - 'n' => array( + 'candidates' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 10, @@ -91,9 +91,9 @@ protected function execute_callback( $input ) { $args = wp_parse_args( $input, array( - 'content' => null, - 'post_id' => null, - 'n' => 3, + 'content' => null, + 'post_id' => null, + 'candidates' => 3, ), ); @@ -131,7 +131,7 @@ protected function execute_callback( $input ) { } // Generate the titles. - $result = $this->generate_titles( $context, $args['n'] ); + $result = $this->generate_titles( $context, $args['candidates'] ); // If we have an error, return it. if ( is_wp_error( $result ) ) { @@ -215,10 +215,10 @@ protected function meta(): array { * @since 0.1.0 * * @param string|array $context The context to generate a title from. - * @param int $n The number of titles to generate. + * @param int $candidates The number of titles to generate. * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. */ - protected function generate_titles( $context, int $n = 1 ) { + protected function generate_titles( $context, int $candidates = 1 ) { // Convert the context to a string if it's an array. if ( is_array( $context ) ) { $context = implode( @@ -243,7 +243,7 @@ static function ( $key, $value ) { '"""' . $context . '"""', $this->get_system_instruction(), array( - 'candidateCount' => (int) $n, + 'candidateCount' => (int) $candidates, 'temperature' => 0.7, ) ); diff --git a/includes/Abilities/Title_Generation/system-instruction.php b/includes/Abilities/Title_Generation/system-instruction.php index 400ce506..e4539bce 100644 --- a/includes/Abilities/Title_Generation/system-instruction.php +++ b/includes/Abilities/Title_Generation/system-instruction.php @@ -5,6 +5,7 @@ * @package WordPress\AI\Abilities\Title_Generation */ +// phpcs:ignore Squiz.PHP.Heredoc.NotAllowed return <<<'INSTRUCTION' You are an editorial assistant that generates title suggestions for online articles and pages. diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index 3af8c8fd..51137f17 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -142,7 +142,7 @@ public function test_input_schema_returns_expected_structure() { $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( 'n', $schema['properties'], 'Schema should have n property' ); + $this->assertArrayHasKey( 'candidates', $schema['properties'], 'Schema should have candidates property' ); // Verify content property. $this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' ); @@ -153,9 +153,9 @@ public function test_input_schema_returns_expected_structure() { $this->assertEquals( 'absint', $schema['properties']['post_id']['sanitize_callback'], 'Post ID should use absint' ); // Verify n property. - $this->assertEquals( 'integer', $schema['properties']['n']['type'], 'n should be integer type' ); - $this->assertEquals( 1, $schema['properties']['n']['minimum'], 'n minimum should be 1' ); - $this->assertEquals( 10, $schema['properties']['n']['maximum'], 'n maximum should be 10' ); + $this->assertEquals( 'integer', $schema['properties']['candidates']['type'], 'candidates should be integer type' ); + $this->assertEquals( 1, $schema['properties']['candidates']['minimum'], 'candidates minimum should be 1' ); + $this->assertEquals( 10, $schema['properties']['candidates']['maximum'], 'candidates maximum should be 10' ); } /** From ca74ff17cfad071363cb67974734ed4982ea69d1 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 15:46:43 -0700 Subject: [PATCH 35/43] Remove the API_Request class and move a few methods into helper functions. Use this new helper function to get a prompt builder and then use that to make our requests --- includes/API_Request.php | 238 ------------------ .../Title_Generation/Title_Generation.php | 38 +-- includes/Helpers.php | 130 ++++++++++ 3 files changed, 154 insertions(+), 252 deletions(-) delete mode 100644 includes/API_Request.php diff --git a/includes/API_Request.php b/includes/API_Request.php deleted file mode 100644 index d6275f9f..00000000 --- a/includes/API_Request.php +++ /dev/null @@ -1,238 +0,0 @@ - - */ - protected array $model_preferences = array( - array( - 'anthropic', - 'claude-haiku-4-5', - ), - array( - 'google', - 'gemini-2.5-flash', - ), - array( - 'openai', - 'gpt-4o-mini', - ), - array( - 'openai', - 'gpt-4.1', - ), - ); - - /** - * Constructor. - * - * @since 0.1.0 - * - * @param string $provider The desired AI provider. - * @param string $model The desired AI model. - */ - public function __construct( ?string $provider = null, ?string $model = null ) { - $this->provider = $provider ?? null; - $this->model = $model ?? null; - } - - /** - * Make a text generation request using the AI SDK Client. - * - * @since 0.1.0 - * - * @param string|null $prompt The prompt to send. - * @param string|null $system_instruction The system instruction to send. - * @param array $options The options to send. - * @return array|\WP_Error The result of the request. - */ - public function generate_text( $prompt = null, $system_instruction = null, array $options = array() ) { - if ( ! $this->is_client_available() ) { - return new WP_Error( 'ai_client_not_available', __( 'AI Client is not available', 'ai' ) ); - } - - $prompt_builder = $this->prompt_builder( $prompt, $system_instruction, $options ); - - if ( is_wp_error( $prompt_builder ) ) { - return $prompt_builder; - } - - try { - return $this->get_result( $prompt_builder->generate_texts() ); - } catch ( Throwable $t ) { - return new WP_Error( 'ai_client_error', $t->getMessage() ); - } - } - - /** - * Build the prompt builder for the request. - * - * @since 0.1.0 - * - * @param string|null $prompt The prompt to send. - * @param string|null $system_instruction The system instruction to send. - * @param array $options The options to send. - * @return \WordPress\AI_Client\Builders\Prompt_Builder|\WP_Error The prompt builder or a WP_Error. - */ - protected function prompt_builder( $prompt = null, $system_instruction = null, array $options = array() ) { - try { - $model_config = $this->process_model_config( $options ); - $prompt_builder = AI_Client::prompt( $prompt ); - $prompt_builder = $prompt_builder->using_model_config( $model_config ); - - if ( ! empty( $system_instruction ) ) { - $prompt_builder = $prompt_builder->using_system_instruction( $system_instruction ); - } - - if ( ! empty( $this->provider ) ) { - $prompt_builder = $prompt_builder->using_provider( $this->provider ); - - // Set the model. - if ( ! empty( $this->model ) ) { - $registry = AiClient::defaultRegistry(); - $provider_class_name = $registry->getProviderClassName( $this->provider ); - $prompt_builder = $prompt_builder->using_model( $provider_class_name::model( $this->model ) ); - } - } - - // Set our preferred models if no model is specified. - if ( empty( $this->model ) ) { - $prompt_builder = $prompt_builder->using_model_preference( ...$this->model_preferences ); - } - - return $prompt_builder; - } catch ( Throwable $t ) { - return new WP_Error( 'ai_client_error', $t->getMessage() ); - } - } - - /** - * Process the response from the AI SDK Client. - * - * @since 0.1.0 - * - * @param array $response The response from the AI SDK Client. - * @return array|\WP_Error - */ - protected function get_result( array $response ) { - if ( empty( $response ) ) { - return new WP_Error( 'no_choices', __( 'No choices were returned from the AI provider', 'ai' ) ); - } - - $results = array(); - foreach ( $response as $choice ) { - $results[] = $this->sanitize_choice( $choice ); - } - - return $results; - } - - /** - * Sanitize a choice from AI response. - * - * @since 0.1.0 - * - * @param string $choice The choice to sanitize. - * @return string - */ - protected function sanitize_choice( string $choice ): string { - return sanitize_text_field( trim( $choice, ' "\'' ) ); - } - - /** - * Process the model config. - * - * @since 0.1.0 - * - * @param array $options The options to add to the model config. - * @return \WordPress\AiClient\Providers\Models\DTO\ModelConfig - */ - protected function process_model_config( array $options = array() ): ModelConfig { - $schema = ModelConfig::getJsonSchema()['properties']; - $model_config = array(); - - foreach ( $options as $key => $value ) { - if ( ! isset( $schema[ $key ] ) ) { - continue; - } - - $property_schema = $schema[ $key ]; - $type = $property_schema['type'] ?? null; - - $processed_value = (string) $value; - - if ( 'array' === $type || 'object' === $type ) { - $processed_value = (array) $value; - } elseif ( 'integer' === $type ) { - $processed_value = (int) $value; - } elseif ( 'number' === $type ) { - $processed_value = (float) $value; - } elseif ( 'boolean' === $type ) { - $processed_value = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); - - if ( null === $processed_value ) { - continue; - } - } - - $model_config[ $key ] = $processed_value; - } - - // @phpstan-ignore-next-line - fromArray() validates the array shape at runtime. - return ModelConfig::fromArray( $model_config ); - } - - /** - * Check if the AI SDK Client is available. - * - * @since 0.1.0 - * - * @return bool True if the client is available, false otherwise. - */ - protected function is_client_available(): bool { - return class_exists( AI_Client::class ); - } -} diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index 11ce201d..a3f0c9e7 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -10,10 +10,10 @@ namespace WordPress\AI\Abilities\Title_Generation; use WP_Error; -use WordPress\AI\API_Request; use WordPress\AI\Abstracts\Abstract_Ability; use function WordPress\AI\get_post_context; +use function WordPress\AI\get_prompt_builder; use function WordPress\AI\normalize_content; /** @@ -138,8 +138,23 @@ protected function execute_callback( $input ) { return $result; } + // If we have no results, return an error. + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No title suggestions were generated.', 'ai' ) + ); + } + // Return the titles in the format the Ability expects. - return array( 'titles' => $result ); + return array( + 'titles' => array_map( + static function ( $title ) { + return sanitize_text_field( trim( $title, ' "\'' ) ); + }, + $result + ), + ); } /** @@ -237,22 +252,17 @@ static function ( $key, $value ) { ); } - // Make our request. - $request = new API_Request(); - $response = $request->generate_text( + // Get our prompt builder. + $prompt_builder = get_prompt_builder( '"""' . $context . '"""', - $this->get_system_instruction(), array( - 'candidateCount' => (int) $candidates, - 'temperature' => 0.7, + 'candidateCount' => (int) $candidates, + 'systemInstruction' => $this->get_system_instruction(), + 'temperature' => 0.7, ) ); - // If we have an error, return it. - if ( is_wp_error( $response ) ) { - return $response; - } - - return $response; + // Make the request. + return $prompt_builder->generate_texts(); } } diff --git a/includes/Helpers.php b/includes/Helpers.php index cb056d14..7e58ecc6 100644 --- a/includes/Helpers.php +++ b/includes/Helpers.php @@ -9,6 +9,10 @@ namespace WordPress\AI; +use WordPress\AI_Client\AI_Client; +use WordPress\AiClient\AiClient; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; + /** * Normalizes the content by cleaning it and removing unwanted HTML tags. * @@ -119,3 +123,129 @@ function get_post_context( int $post_id ): array { return $context; } + +/** + * Returns the preferred models. + * + * @since 0.1.0 + * + * @return array The preferred models. + */ +function get_preferred_models(): array { + $preferred_models = array( + array( + 'anthropic', + 'claude-haiku-4-5', + ), + array( + 'google', + 'gemini-2.5-flash', + ), + array( + 'openai', + 'gpt-4o-mini', + ), + array( + 'openai', + 'gpt-4.1', + ), + ); + + /** + * Filters the preferred models. + * + * @since 0.1.0 + * @hook ai_preferred_models + * + * @param array $preferred_models The preferred models. + * @return array The filtered preferred models. + */ + return (array) apply_filters( 'ai_preferred_models', $preferred_models ); +} + +/** + * Get a prompt builder. + * + * @since 0.1.0 + * + * @param string|null $prompt The prompt to send. + * @param array $options The options to send. + * @return \WordPress\AI_Client\Builders\Prompt_Builder The prompt builder. + */ +function get_prompt_builder( $prompt = null, array $options = array() ) { + // Default arguments. + $args = wp_parse_args( + $options, + array( + 'model' => null, + 'provider' => null, + ), + ); + + unset( $options['model'], $options['provider'] ); + + $model_config = process_model_config( $options ); + $prompt_builder = AI_Client::prompt_with_wp_error( $prompt ); + $prompt_builder = $prompt_builder->using_model_config( $model_config ); + + if ( ! empty( $args['provider'] ) ) { + $prompt_builder = $prompt_builder->using_provider( $args['provider'] ); + + // Set the model. + if ( ! empty( $args['model'] ) ) { + $registry = AiClient::defaultRegistry(); + $provider_class_name = $registry->getProviderClassName( $args['provider'] ); + $prompt_builder = $prompt_builder->using_model( $provider_class_name::model( $args['model'] ) ); + } + } + + // Set our preferred models if no model is specified. + if ( empty( $args['model'] ) ) { + $prompt_builder = $prompt_builder->using_model_preference( ...get_preferred_models() ); + } + + return $prompt_builder; +} + +/** + * Process the model config. + * + * @since 0.1.0 + * + * @param array $options The options to add to the model config. + * @return \WordPress\AiClient\Providers\Models\DTO\ModelConfig + */ +function process_model_config( array $options = array() ): ModelConfig { + $schema = ModelConfig::getJsonSchema()['properties']; + $model_config = array(); + + foreach ( $options as $key => $value ) { + if ( ! isset( $schema[ $key ] ) ) { + continue; + } + + $property_schema = $schema[ $key ]; + $type = $property_schema['type'] ?? null; + + $processed_value = (string) $value; + + if ( 'array' === $type || 'object' === $type ) { + $processed_value = (array) $value; + } elseif ( 'integer' === $type ) { + $processed_value = (int) $value; + } elseif ( 'number' === $type ) { + $processed_value = (float) $value; + } elseif ( 'boolean' === $type ) { + $processed_value = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); + + if ( null === $processed_value ) { + continue; + } + } + + $model_config[ $key ] = $processed_value; + } + + // @phpstan-ignore-next-line - fromArray() validates the array shape at runtime. + return ModelConfig::fromArray( $model_config ); +} From 009ef44b95bf08f0fff61f70b2809dc398d16d92 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 15:46:48 -0700 Subject: [PATCH 36/43] Update tests --- .../Integration/Includes/API_RequestTest.php | 258 ----------- .../Abilities/Title_GenerationTest.php | 174 +++++-- tests/Integration/Includes/HelpersTest.php | 435 ++++++++++++++++++ 3 files changed, 582 insertions(+), 285 deletions(-) delete mode 100644 tests/Integration/Includes/API_RequestTest.php create mode 100644 tests/Integration/Includes/HelpersTest.php diff --git a/tests/Integration/Includes/API_RequestTest.php b/tests/Integration/Includes/API_RequestTest.php deleted file mode 100644 index e09e8761..00000000 --- a/tests/Integration/Includes/API_RequestTest.php +++ /dev/null @@ -1,258 +0,0 @@ -getProperty( 'provider' ); - $provider_property->setAccessible( true ); - $model_property = $reflection->getProperty( 'model' ); - $model_property->setAccessible( true ); - - $this->assertEquals( 'openai', $provider_property->getValue( $request ), 'Provider should be set' ); - $this->assertEquals( 'gpt-4', $model_property->getValue( $request ), 'Model should be set' ); - } - - /** - * Test that constructor handles empty strings. - * - * @since 0.1.0 - */ - public function test_constructor_handles_empty_strings() { - $request = new API_Request( '', '' ); - - $reflection = new \ReflectionClass( $request ); - $provider_property = $reflection->getProperty( 'provider' ); - $provider_property->setAccessible( true ); - $model_property = $reflection->getProperty( 'model' ); - $model_property->setAccessible( true ); - - $this->assertEquals( '', $provider_property->getValue( $request ), 'Provider should be empty string' ); - $this->assertEquals( '', $model_property->getValue( $request ), 'Model should be empty string' ); - } - - /** - * Test that is_client_available() checks for AiClient class. - * - * @since 0.1.0 - */ - public function test_is_client_available_checks_class() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'is_client_available' ); - $method->setAccessible( true ); - - $result = $method->invoke( $request ); - - // The result depends on whether AiClient class exists in the test environment. - $this->assertIsBool( $result, 'Should return a boolean' ); - } - - /** - * Test that generate_text() returns error when client is not available. - * - * @since 0.1.0 - */ - public function test_generate_text_returns_error_when_client_unavailable() { - $request = new API_Request(); - - // Mock the is_client_available method to return false. - $mock = $this->getMockBuilder( API_Request::class ) - ->onlyMethods( array( 'is_client_available' ) ) - ->getMock(); - - $mock->expects( $this->once() ) - ->method( 'is_client_available' ) - ->willReturn( false ); - - $result = $mock->generate_text( 'test prompt' ); - - $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error when client unavailable' ); - $this->assertEquals( 'ai_client_not_available', $result->get_error_code(), 'Error code should be ai_client_not_available' ); - } - - /** - * Test that sanitize_choice() sanitizes text correctly. - * - * @since 0.1.0 - */ - public function test_sanitize_choice_sanitizes_text() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'sanitize_choice' ); - $method->setAccessible( true ); - - // Test trimming quotes. - $result = $method->invoke( $request, '"Test Title"' ); - $this->assertEquals( 'Test Title', $result, 'Should remove double quotes' ); - - $result = $method->invoke( $request, "'Test Title'" ); - $this->assertEquals( 'Test Title', $result, 'Should remove single quotes' ); - - // Test trimming whitespace. - $result = $method->invoke( $request, ' Test Title ' ); - $this->assertEquals( 'Test Title', $result, 'Should trim whitespace' ); - - // Test sanitize_text_field behavior (removes HTML). - $result = $method->invoke( $request, 'Test Title' ); - $this->assertEquals( 'Test Title', $result, 'Should sanitize HTML' ); - } - - /** - * Test that get_result() returns error for empty response. - * - * @since 0.1.0 - */ - public function test_get_result_returns_error_for_empty_response() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'get_result' ); - $method->setAccessible( true ); - - $result = $method->invoke( $request, array() ); - - $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for empty response' ); - $this->assertEquals( 'no_choices', $result->get_error_code(), 'Error code should be no_choices' ); - } - - /** - * Test that get_result() processes choices correctly. - * - * @since 0.1.0 - */ - public function test_get_result_processes_choices() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'get_result' ); - $method->setAccessible( true ); - - $response = array( - ' Title 1 ', - '"Title 2"', - "'Title 3'", - ); - - $result = $method->invoke( $request, $response ); - - $this->assertIsArray( $result, 'Should return an array' ); - $this->assertCount( 3, $result, 'Should have 3 choices' ); - $this->assertEquals( 'Title 1', $result[0], 'Should sanitize first choice' ); - $this->assertEquals( 'Title 2', $result[1], 'Should sanitize second choice' ); - $this->assertEquals( 'Title 3', $result[2], 'Should sanitize third choice' ); - } - - /** - * Test that process_model_config() processes string values. - * - * @since 0.1.0 - */ - public function test_process_model_config_processes_string_values() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'process_model_config' ); - $method->setAccessible( true ); - - $options = array( - 'temperature' => '0.7', - ); - - $result = $method->invoke( $request, $options ); - - $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); - } - - /** - * Test that process_model_config() processes integer values. - * - * @since 0.1.0 - */ - public function test_process_model_config_processes_integer_values() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'process_model_config' ); - $method->setAccessible( true ); - - $options = array( - 'candidateCount' => '5', - ); - - $result = $method->invoke( $request, $options ); - - $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); - } - - /** - * Test that process_model_config() processes boolean values. - * - * @since 0.1.0 - */ - public function test_process_model_config_processes_boolean_values() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'process_model_config' ); - $method->setAccessible( true ); - - $options = array( - 'someBoolean' => 'true', - ); - - // This will only work if 'someBoolean' is in the ModelConfig schema. - // Otherwise it will be skipped. We'll just verify it doesn't error. - $result = $method->invoke( $request, $options ); - - $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); - } - - /** - * Test that process_model_config() skips invalid options. - * - * @since 0.1.0 - */ - public function test_process_model_config_skips_invalid_options() { - $request = new API_Request(); - - $reflection = new \ReflectionClass( $request ); - $method = $reflection->getMethod( 'process_model_config' ); - $method->setAccessible( true ); - - $options = array( - 'invalid_option' => 'value', - 'temperature' => '0.7', - ); - - $result = $method->invoke( $request, $options ); - - $this->assertInstanceOf( \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, $result, 'Should return ModelConfig instance' ); - } -} - diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index 51137f17..6066f69f 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -42,23 +42,6 @@ public function register(): void { // No-op for testing. } - /** - * Generates title suggestions from the given context. - * - * @since 0.1.0 - * - * @param string|array $context The context to generate a title from. - * @param int $n The number of titles to generate. - * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. - */ - public function generate_titles( $context, int $n = 1 ) { - // For testing, return mock titles. - $titles = array(); - for ( $i = 1; $i <= $n; $i++ ) { - $titles[] = "Generated Title {$i}"; - } - return $titles; - } } /** @@ -152,7 +135,7 @@ public function test_input_schema_returns_expected_structure() { $this->assertEquals( 'integer', $schema['properties']['post_id']['type'], 'Post ID should be integer type' ); $this->assertEquals( 'absint', $schema['properties']['post_id']['sanitize_callback'], 'Post ID should use absint' ); - // Verify n property. + // Verify candidates property. $this->assertEquals( 'integer', $schema['properties']['candidates']['type'], 'candidates should be integer type' ); $this->assertEquals( 1, $schema['properties']['candidates']['minimum'], 'candidates minimum should be 1' ); $this->assertEquals( 10, $schema['properties']['candidates']['maximum'], 'candidates maximum should be 10' ); @@ -203,10 +186,16 @@ public function test_execute_callback_with_content() { $method->setAccessible( true ); $input = array( - 'content' => 'This is some test content.', - 'n' => 3, + 'content' => 'This is some test content.', + 'candidates' => 3, ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } // Result may be array (success) or WP_Error (if AI client unavailable). if ( is_wp_error( $result ) ) { @@ -239,10 +228,16 @@ public function test_execute_callback_with_post_id() { ); $input = array( - 'post_id' => $post_id, - 'n' => 2, + 'post_id' => $post_id, + 'candidates' => 2, ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } // Result may be array (success) or WP_Error (if AI client unavailable). if ( is_wp_error( $result ) ) { @@ -305,7 +300,13 @@ public function test_execute_callback_uses_defaults() { $input = array( 'content' => 'Test content', ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } // Result may be array (success) or WP_Error (if AI client unavailable). if ( is_wp_error( $result ) ) { @@ -316,7 +317,7 @@ public function test_execute_callback_uses_defaults() { $this->assertIsArray( $result, 'Result should be an array' ); $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); $this->assertIsArray( $result['titles'], 'Titles should be an array' ); - $this->assertCount( 1, $result['titles'], 'Should have 1 title by default' ); + $this->assertCount( 3, $result['titles'], 'Should have 3 titles by default' ); } /** @@ -341,7 +342,13 @@ public function test_execute_callback_post_id_overrides_content() { 'content' => 'This content should be ignored.', 'post_id' => $post_id, ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } // Result may be array (success) or WP_Error (if AI client unavailable). if ( is_wp_error( $result ) ) { @@ -414,6 +421,119 @@ public function test_permission_callback_for_logged_out_user() { $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); } + /** + * Test that permission_callback() returns true for user with read_post capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_post_id_and_read_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_status' => 'publish', + ) + ); + + // Create a user with read capability. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertTrue( $result, 'Permission should be granted for user with read_post capability' ); + } + + /** + * Test that permission_callback() returns error for user without read_post capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_post_id_without_read_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a private test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_status' => 'private', + ) + ); + + // Create a user without read capability for this post. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $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 non-existent post. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_nonexistent_post_id() { + $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( 'post_id' => 99999 ) ); + + $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 permission_callback() returns false for post type without show_in_rest. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_post_type_without_show_in_rest() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Register a custom post type without show_in_rest. + register_post_type( + 'test_no_rest', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + // Create a test post with this post type. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_type' => 'test_no_rest', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertFalse( $result, 'Permission should be denied for post type without show_in_rest' ); + + // Clean up. + unregister_post_type( 'test_no_rest' ); + } + /** * Test that meta() returns the expected meta structure. * diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php new file mode 100644 index 00000000..2c1352d5 --- /dev/null +++ b/tests/Integration/Includes/HelpersTest.php @@ -0,0 +1,435 @@ +assertStringNotContainsString( '&', $result, 'Should remove HTML entities' ); + $this->assertStringNotContainsString( '<', $result, 'Should remove HTML entities' ); + $this->assertStringNotContainsString( '>', $result, 'Should remove HTML entities' ); + } + + /** + * Test that normalize_content() replaces HTML linebreaks with newlines. + * + * @since 0.1.0 + */ + public function test_normalize_content_replaces_linebreaks() { + $content = 'Line 1
Line 2
Line 3'; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertStringNotContainsString( '
', $result, 'Should remove br tags' ); + $this->assertStringContainsString( "\n\n", $result, 'Should replace br with newlines' ); + } + + /** + * Test that normalize_content() strips HTML tags. + * + * @since 0.1.0 + */ + public function test_normalize_content_strips_html_tags() { + $content = '

Test content with HTML

'; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertStringNotContainsString( '

', $result, 'Should remove HTML tags' ); + $this->assertStringNotContainsString( '', $result, 'Should remove HTML tags' ); + $this->assertStringNotContainsString( '', $result, 'Should remove HTML tags' ); + $this->assertStringContainsString( 'Test content with HTML', $result, 'Should preserve text content' ); + } + + /** + * Test that normalize_content() removes unrendered shortcode tags. + * + * @since 0.1.0 + */ + public function test_normalize_content_removes_shortcode_tags() { + $content = '[shortcode]content[/shortcode]'; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertStringNotContainsString( '[shortcode]', $result, 'Should remove shortcode tags' ); + $this->assertStringContainsString( 'content', $result, 'Should preserve shortcode content' ); + } + + /** + * Test that normalize_content() trims whitespace. + * + * @since 0.1.0 + */ + public function test_normalize_content_trims_whitespace() { + $content = ' Test content '; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertEquals( 'Test content', $result, 'Should trim whitespace' ); + } + + /** + * Test that normalize_content() applies filters. + * + * @since 0.1.0 + */ + public function test_normalize_content_applies_filters() { + add_filter( 'ai_pre_normalize_content', function( $content ) { + return 'Filtered: ' . $content; + } ); + + $result = \WordPress\AI\normalize_content( 'test' ); + + $this->assertStringContainsString( 'Filtered:', $result, 'Should apply pre-normalize filter' ); + + remove_all_filters( 'ai_pre_normalize_content' ); + } + + /** + * Test that get_post_context() returns empty array for non-existent post. + * + * @since 0.1.0 + */ + public function test_get_post_context_returns_empty_for_nonexistent_post() { + $context = \WordPress\AI\get_post_context( 99999 ); + + $this->assertIsArray( $context, 'Should return an array' ); + $this->assertEmpty( $context, 'Should return empty array for non-existent post' ); + } + + /** + * Test that get_post_context() returns post content. + * + * @since 0.1.0 + */ + public function test_get_post_context_returns_post_content() { + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test post content', + 'post_title' => 'Test Post', + ) + ); + + $context = \WordPress\AI\get_post_context( $post_id ); + + $this->assertIsArray( $context, 'Should return an array' ); + $this->assertArrayHasKey( 'content', $context, 'Should have content key' ); + $this->assertNotEmpty( $context['content'], 'Content should not be empty' ); + } + + /** + * Test that get_post_context() returns post metadata. + * + * @since 0.1.0 + */ + public function test_get_post_context_returns_post_metadata() { + $author_id = $this->factory->user->create( array( 'display_name' => 'Test Author' ) ); + $post_id = $this->factory->post->create( + array( + 'post_title' => 'Test Post Title', + 'post_name' => 'test-post-slug', + 'post_author' => $author_id, + 'post_type' => 'post', + 'post_excerpt' => 'Test excerpt', + ) + ); + + $context = \WordPress\AI\get_post_context( $post_id ); + + $this->assertArrayHasKey( 'current_title', $context, 'Should have current_title' ); + $this->assertEquals( 'Test Post Title', $context['current_title'], 'Title should match' ); + $this->assertArrayHasKey( 'slug', $context, 'Should have slug' ); + $this->assertEquals( 'test-post-slug', $context['slug'], 'Slug should match' ); + $this->assertArrayHasKey( 'author', $context, 'Should have author' ); + $this->assertEquals( 'Test Author', $context['author'], 'Author should match' ); + $this->assertArrayHasKey( 'content_type', $context, 'Should have content_type' ); + $this->assertEquals( 'post', $context['content_type'], 'Content type should match' ); + $this->assertArrayHasKey( 'excerpt', $context, 'Should have excerpt' ); + $this->assertEquals( 'Test excerpt', $context['excerpt'], 'Excerpt should match' ); + } + + /** + * Test that get_post_context() includes categories and tags. + * + * @since 0.1.0 + */ + public function test_get_post_context_includes_categories_and_tags() { + $category_id = $this->factory->category->create( array( 'name' => 'Test Category' ) ); + $tag_id = $this->factory->tag->create( array( 'name' => 'Test Tag' ) ); + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + ) + ); + + wp_set_post_categories( $post_id, array( $category_id ) ); + wp_set_post_tags( $post_id, array( 'Test Tag' ) ); + + $context = \WordPress\AI\get_post_context( $post_id ); + + $this->assertArrayHasKey( 'categories', $context, 'Should have categories' ); + $this->assertStringContainsString( 'Test Category', $context['categories'], 'Should include category name' ); + $this->assertArrayHasKey( 'tags', $context, 'Should have tags' ); + $this->assertStringContainsString( 'Test Tag', $context['tags'], 'Should include tag name' ); + } + + /** + * Test that get_prompt_builder() returns Prompt_Builder instance. + * + * @since 0.1.0 + */ + public function test_get_prompt_builder_returns_prompt_builder() { + $prompt_builder = \WordPress\AI\get_prompt_builder( 'test prompt' ); + + // May return WP_Error if AI client not available. + if ( is_wp_error( $prompt_builder ) ) { + $this->markTestSkipped( 'AI client not available: ' . $prompt_builder->get_error_message() ); + return; + } + + $this->assertInstanceOf( + \WordPress\AI_Client\Builders\Prompt_Builder::class, + $prompt_builder, + 'Should return Prompt_Builder instance' + ); + } + + /** + * Test that get_prompt_builder() handles options correctly. + * + * @since 0.1.0 + */ + public function test_get_prompt_builder_handles_options() { + $prompt_builder = \WordPress\AI\get_prompt_builder( + 'test prompt', + array( + 'temperature' => 0.7, + ) + ); + + // May return WP_Error if AI client not available. + if ( is_wp_error( $prompt_builder ) ) { + $this->markTestSkipped( 'AI client not available: ' . $prompt_builder->get_error_message() ); + return; + } + + $this->assertInstanceOf( + \WordPress\AI_Client\Builders\Prompt_Builder::class, + $prompt_builder, + 'Should return Prompt_Builder instance' + ); + } + + /** + * Test that get_prompt_builder() returns error on exception. + * + * @since 0.1.0 + */ + public function test_get_prompt_builder_returns_error_on_exception() { + // This test is hard to trigger without mocking, but we can verify + // the error handling structure exists. + $prompt_builder = \WordPress\AI\get_prompt_builder( null, array() ); + + // Should either return Prompt_Builder or WP_Error. + $this->assertTrue( + $prompt_builder instanceof \WordPress\AI_Client\Builders\Prompt_Builder || is_wp_error( $prompt_builder ), + 'Should return Prompt_Builder or WP_Error' + ); + } + + /** + * Test that process_model_config() processes string values. + * + * @since 0.1.0 + */ + public function test_process_model_config_processes_string_values() { + $options = array( + 'temperature' => '0.7', + ); + + $result = \WordPress\AI\process_model_config( $options ); + + $this->assertInstanceOf( + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, + $result, + 'Should return ModelConfig instance' + ); + } + + /** + * Test that process_model_config() processes integer values. + * + * @since 0.1.0 + */ + public function test_process_model_config_processes_integer_values() { + $options = array( + 'candidateCount' => '5', + ); + + $result = \WordPress\AI\process_model_config( $options ); + + $this->assertInstanceOf( + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, + $result, + 'Should return ModelConfig instance' + ); + } + + /** + * Test that process_model_config() processes boolean values. + * + * @since 0.1.0 + */ + public function test_process_model_config_processes_boolean_values() { + $options = array( + 'logprobs' => 'true', + ); + + $result = \WordPress\AI\process_model_config( $options ); + + $this->assertInstanceOf( + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, + $result, + 'Should return ModelConfig instance' + ); + } + + /** + * Test that process_model_config() skips invalid options. + * + * @since 0.1.0 + */ + public function test_process_model_config_skips_invalid_options() { + $options = array( + 'invalid_option' => 'value', + 'temperature' => '0.7', + ); + + $result = \WordPress\AI\process_model_config( $options ); + + $this->assertInstanceOf( + \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, + $result, + 'Should return ModelConfig instance' + ); + } + + /** + * Test that get_preferred_models() returns an array. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_returns_array() { + $result = \WordPress\AI\get_preferred_models(); + + $this->assertIsArray( $result, 'Should return an array' ); + $this->assertNotEmpty( $result, 'Should not be empty' ); + } + + /** + * Test that get_preferred_models() returns expected default models. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_returns_default_models() { + $result = \WordPress\AI\get_preferred_models(); + + $this->assertCount( 4, $result, 'Should have 4 preferred models' ); + + // Check first model (anthropic). + $this->assertIsArray( $result[0], 'First model should be an array' ); + $this->assertCount( 2, $result[0], 'First model should have 2 elements' ); + $this->assertEquals( 'anthropic', $result[0][0], 'First model provider should be anthropic' ); + $this->assertEquals( 'claude-haiku-4-5', $result[0][1], 'First model name should be claude-haiku-4-5' ); + + // Check second model (google). + $this->assertIsArray( $result[1], 'Second model should be an array' ); + $this->assertCount( 2, $result[1], 'Second model should have 2 elements' ); + $this->assertEquals( 'google', $result[1][0], 'Second model provider should be google' ); + $this->assertEquals( 'gemini-2.5-flash', $result[1][1], 'Second model name should be gemini-2.5-flash' ); + + // Check third model (openai). + $this->assertIsArray( $result[2], 'Third model should be an array' ); + $this->assertCount( 2, $result[2], 'Third model should have 2 elements' ); + $this->assertEquals( 'openai', $result[2][0], 'Third model provider should be openai' ); + $this->assertEquals( 'gpt-4o-mini', $result[2][1], 'Third model name should be gpt-4o-mini' ); + + // Check fourth model (openai). + $this->assertIsArray( $result[3], 'Fourth model should be an array' ); + $this->assertCount( 2, $result[3], 'Fourth model should have 2 elements' ); + $this->assertEquals( 'openai', $result[3][0], 'Fourth model provider should be openai' ); + $this->assertEquals( 'gpt-4.1', $result[3][1], 'Fourth model name should be gpt-4.1' ); + } + + /** + * Test that get_preferred_models() applies filter. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_applies_filter() { + add_filter( + 'ai_preferred_models', + function( $models ) { + // Add a custom model. + $models[] = array( + 'custom', + 'custom-model', + ); + return $models; + } + ); + + $result = \WordPress\AI\get_preferred_models(); + + $this->assertCount( 5, $result, 'Should have 5 models after filter' ); + $this->assertEquals( 'custom', $result[4][0], 'Fifth model provider should be custom' ); + $this->assertEquals( 'custom-model', $result[4][1], 'Fifth model name should be custom-model' ); + + remove_all_filters( 'ai_preferred_models' ); + } + + /** + * Test that get_preferred_models() filter can replace models. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_filter_can_replace_models() { + add_filter( + 'ai_preferred_models', + function( $models ) { + // Replace with a single model. + return array( + array( + 'test', + 'test-model', + ), + ); + } + ); + + $result = \WordPress\AI\get_preferred_models(); + + $this->assertCount( 1, $result, 'Should have 1 model after filter replacement' ); + $this->assertEquals( 'test', $result[0][0], 'Model provider should be test' ); + $this->assertEquals( 'test-model', $result[0][1], 'Model name should be test-model' ); + + remove_all_filters( 'ai_preferred_models' ); + } +} + From 94dff32f681379cfe6ca33230459c35828190269 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 16:30:26 -0700 Subject: [PATCH 37/43] Remove prompt builder helper methods. Directly integrate the prompt builder into the ability execution --- .../Title_Generation/Title_Generation.php | 18 ++-- includes/Helpers.php | 91 ------------------- 2 files changed, 8 insertions(+), 101 deletions(-) diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index a3f0c9e7..4d8499bd 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -11,9 +11,10 @@ use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; +use WordPress\AI_Client\AI_Client; use function WordPress\AI\get_post_context; -use function WordPress\AI\get_prompt_builder; +use function WordPress\AI\get_preferred_models; use function WordPress\AI\normalize_content; /** @@ -252,15 +253,12 @@ static function ( $key, $value ) { ); } - // Get our prompt builder. - $prompt_builder = get_prompt_builder( - '"""' . $context . '"""', - array( - 'candidateCount' => (int) $candidates, - 'systemInstruction' => $this->get_system_instruction(), - 'temperature' => 0.7, - ) - ); + // Get the prompt builder. + $prompt_builder = AI_Client::prompt_with_wp_error( '"""' . $context . '"""' ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( 0.7 ) + ->using_candidate_count( (int) $candidates ) + ->using_model_preference( ...get_preferred_models() ); // Make the request. return $prompt_builder->generate_texts(); diff --git a/includes/Helpers.php b/includes/Helpers.php index 7e58ecc6..34d0f612 100644 --- a/includes/Helpers.php +++ b/includes/Helpers.php @@ -9,10 +9,6 @@ namespace WordPress\AI; -use WordPress\AI_Client\AI_Client; -use WordPress\AiClient\AiClient; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; - /** * Normalizes the content by cleaning it and removing unwanted HTML tags. * @@ -162,90 +158,3 @@ function get_preferred_models(): array { */ return (array) apply_filters( 'ai_preferred_models', $preferred_models ); } - -/** - * Get a prompt builder. - * - * @since 0.1.0 - * - * @param string|null $prompt The prompt to send. - * @param array $options The options to send. - * @return \WordPress\AI_Client\Builders\Prompt_Builder The prompt builder. - */ -function get_prompt_builder( $prompt = null, array $options = array() ) { - // Default arguments. - $args = wp_parse_args( - $options, - array( - 'model' => null, - 'provider' => null, - ), - ); - - unset( $options['model'], $options['provider'] ); - - $model_config = process_model_config( $options ); - $prompt_builder = AI_Client::prompt_with_wp_error( $prompt ); - $prompt_builder = $prompt_builder->using_model_config( $model_config ); - - if ( ! empty( $args['provider'] ) ) { - $prompt_builder = $prompt_builder->using_provider( $args['provider'] ); - - // Set the model. - if ( ! empty( $args['model'] ) ) { - $registry = AiClient::defaultRegistry(); - $provider_class_name = $registry->getProviderClassName( $args['provider'] ); - $prompt_builder = $prompt_builder->using_model( $provider_class_name::model( $args['model'] ) ); - } - } - - // Set our preferred models if no model is specified. - if ( empty( $args['model'] ) ) { - $prompt_builder = $prompt_builder->using_model_preference( ...get_preferred_models() ); - } - - return $prompt_builder; -} - -/** - * Process the model config. - * - * @since 0.1.0 - * - * @param array $options The options to add to the model config. - * @return \WordPress\AiClient\Providers\Models\DTO\ModelConfig - */ -function process_model_config( array $options = array() ): ModelConfig { - $schema = ModelConfig::getJsonSchema()['properties']; - $model_config = array(); - - foreach ( $options as $key => $value ) { - if ( ! isset( $schema[ $key ] ) ) { - continue; - } - - $property_schema = $schema[ $key ]; - $type = $property_schema['type'] ?? null; - - $processed_value = (string) $value; - - if ( 'array' === $type || 'object' === $type ) { - $processed_value = (array) $value; - } elseif ( 'integer' === $type ) { - $processed_value = (int) $value; - } elseif ( 'number' === $type ) { - $processed_value = (float) $value; - } elseif ( 'boolean' === $type ) { - $processed_value = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); - - if ( null === $processed_value ) { - continue; - } - } - - $model_config[ $key ] = $processed_value; - } - - // @phpstan-ignore-next-line - fromArray() validates the array shape at runtime. - return ModelConfig::fromArray( $model_config ); -} From 13aea7aec159581a7127da0407e0580ea042f083 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Nov 2025 16:30:33 -0700 Subject: [PATCH 38/43] Remove unneeded tests --- tests/Integration/Includes/HelpersTest.php | 142 --------------------- 1 file changed, 142 deletions(-) diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index 2c1352d5..971df5f9 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -7,7 +7,6 @@ namespace WordPress\AI\Tests\Integration\Includes; -use WP_Error; use WP_UnitTestCase; /** @@ -189,147 +188,6 @@ public function test_get_post_context_includes_categories_and_tags() { $this->assertStringContainsString( 'Test Tag', $context['tags'], 'Should include tag name' ); } - /** - * Test that get_prompt_builder() returns Prompt_Builder instance. - * - * @since 0.1.0 - */ - public function test_get_prompt_builder_returns_prompt_builder() { - $prompt_builder = \WordPress\AI\get_prompt_builder( 'test prompt' ); - - // May return WP_Error if AI client not available. - if ( is_wp_error( $prompt_builder ) ) { - $this->markTestSkipped( 'AI client not available: ' . $prompt_builder->get_error_message() ); - return; - } - - $this->assertInstanceOf( - \WordPress\AI_Client\Builders\Prompt_Builder::class, - $prompt_builder, - 'Should return Prompt_Builder instance' - ); - } - - /** - * Test that get_prompt_builder() handles options correctly. - * - * @since 0.1.0 - */ - public function test_get_prompt_builder_handles_options() { - $prompt_builder = \WordPress\AI\get_prompt_builder( - 'test prompt', - array( - 'temperature' => 0.7, - ) - ); - - // May return WP_Error if AI client not available. - if ( is_wp_error( $prompt_builder ) ) { - $this->markTestSkipped( 'AI client not available: ' . $prompt_builder->get_error_message() ); - return; - } - - $this->assertInstanceOf( - \WordPress\AI_Client\Builders\Prompt_Builder::class, - $prompt_builder, - 'Should return Prompt_Builder instance' - ); - } - - /** - * Test that get_prompt_builder() returns error on exception. - * - * @since 0.1.0 - */ - public function test_get_prompt_builder_returns_error_on_exception() { - // This test is hard to trigger without mocking, but we can verify - // the error handling structure exists. - $prompt_builder = \WordPress\AI\get_prompt_builder( null, array() ); - - // Should either return Prompt_Builder or WP_Error. - $this->assertTrue( - $prompt_builder instanceof \WordPress\AI_Client\Builders\Prompt_Builder || is_wp_error( $prompt_builder ), - 'Should return Prompt_Builder or WP_Error' - ); - } - - /** - * Test that process_model_config() processes string values. - * - * @since 0.1.0 - */ - public function test_process_model_config_processes_string_values() { - $options = array( - 'temperature' => '0.7', - ); - - $result = \WordPress\AI\process_model_config( $options ); - - $this->assertInstanceOf( - \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, - $result, - 'Should return ModelConfig instance' - ); - } - - /** - * Test that process_model_config() processes integer values. - * - * @since 0.1.0 - */ - public function test_process_model_config_processes_integer_values() { - $options = array( - 'candidateCount' => '5', - ); - - $result = \WordPress\AI\process_model_config( $options ); - - $this->assertInstanceOf( - \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, - $result, - 'Should return ModelConfig instance' - ); - } - - /** - * Test that process_model_config() processes boolean values. - * - * @since 0.1.0 - */ - public function test_process_model_config_processes_boolean_values() { - $options = array( - 'logprobs' => 'true', - ); - - $result = \WordPress\AI\process_model_config( $options ); - - $this->assertInstanceOf( - \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, - $result, - 'Should return ModelConfig instance' - ); - } - - /** - * Test that process_model_config() skips invalid options. - * - * @since 0.1.0 - */ - public function test_process_model_config_skips_invalid_options() { - $options = array( - 'invalid_option' => 'value', - 'temperature' => '0.7', - ); - - $result = \WordPress\AI\process_model_config( $options ); - - $this->assertInstanceOf( - \WordPress\AiClient\Providers\Models\DTO\ModelConfig::class, - $result, - 'Should return ModelConfig instance' - ); - } - /** * Test that get_preferred_models() returns an array. * From 560dc318bc6c36c9f282167b337354c345ce43cc Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 14 Nov 2025 10:10:04 -0700 Subject: [PATCH 39/43] Add class constant to control the default candidate count --- .../Abilities/Title_Generation/Title_Generation.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index 4d8499bd..4f70075a 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -24,6 +24,15 @@ */ class Title_Generation extends Abstract_Ability { + /** + * The default number of candidates to generate. + * + * @since 0.1.0 + * + * @var int + */ + const CANDIDATES_DEFAULT = 3; + /** * Returns the input schema of the ability. * @@ -49,7 +58,7 @@ protected function input_schema(): array { 'type' => 'integer', 'minimum' => 1, 'maximum' => 10, - 'default' => 3, + 'default' => self::CANDIDATES_DEFAULT, 'sanitize_callback' => 'absint', 'description' => esc_html__( 'Number of titles to generate', 'ai' ), ), @@ -94,7 +103,7 @@ protected function execute_callback( $input ) { array( 'content' => null, 'post_id' => null, - 'candidates' => 3, + 'candidates' => self::CANDIDATES_DEFAULT, ), ); From 919b6609ff62ee19237819cbc9473a2c7690d2e6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 14 Nov 2025 10:10:59 -0700 Subject: [PATCH 40/43] Simplify getting our prompt builder and getting results into a single call --- .../Abilities/Title_Generation/Title_Generation.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index 4f70075a..a485af3e 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -262,14 +262,12 @@ static function ( $key, $value ) { ); } - // Get the prompt builder. - $prompt_builder = AI_Client::prompt_with_wp_error( '"""' . $context . '"""' ) + // Generate the titles using the AI client. + return AI_Client::prompt_with_wp_error( '"""' . $context . '"""' ) ->using_system_instruction( $this->get_system_instruction() ) ->using_temperature( 0.7 ) ->using_candidate_count( (int) $candidates ) - ->using_model_preference( ...get_preferred_models() ); - - // Make the request. - return $prompt_builder->generate_texts(); + ->using_model_preference( ...get_preferred_models() ) + ->generate_texts(); } } From 8619a7209fb9193db06115d48d63592754bb3fee Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 14 Nov 2025 10:28:33 -0700 Subject: [PATCH 41/43] Only check for a system-instruction.php file for now --- includes/Abstracts/Abstract_Ability.php | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index c129d603..a8bbe456 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -117,10 +117,6 @@ public function get_system_instruction( ?string $filename = null ): string { /** * Loads system instruction from a PHP file in the feature's directory. * - * Automatic detection order: - * 1. `system-instruction.php` - * 2. `prompt.php` - * * PHP files should return a string directly, e.g.: * ```php * Date: Fri, 14 Nov 2025 10:31:46 -0700 Subject: [PATCH 42/43] Rename file --- composer.json | 2 +- includes/{Helpers.php => helpers.php} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename includes/{Helpers.php => helpers.php} (100%) diff --git a/composer.json b/composer.json index c49beb21..a159ceb7 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "WordPress\\AI\\": "includes/" }, "files": [ - "includes/Helpers.php" + "includes/helpers.php" ] }, "autoload-dev": { diff --git a/includes/Helpers.php b/includes/helpers.php similarity index 100% rename from includes/Helpers.php rename to includes/helpers.php From 7e9c9350bc963cdc6e41eedb38226d41e5fce0be Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 14 Nov 2025 10:33:09 -0700 Subject: [PATCH 43/43] Fix lint issue --- includes/Abilities/Title_Generation/Title_Generation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/Abilities/Title_Generation/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php index a485af3e..4076fc61 100644 --- a/includes/Abilities/Title_Generation/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -31,7 +31,7 @@ class Title_Generation extends Abstract_Ability { * * @var int */ - const CANDIDATES_DEFAULT = 3; + protected const CANDIDATES_DEFAULT = 3; /** * Returns the input schema of the ability.