Skip to content

Commit e1d774b

Browse files
jeffpauldkotterJasonTheAdamsRef34tkarmatosed
authored
Merge pull request #136 from dkotter/feature/content-summarization-experiment
Add the Content Summarization Experiment base Unlinked contributors: prabinjha. Co-authored-by: dkotter <dkotter@git.wordpress.org> Co-authored-by: JasonTheAdams <jason_the_adams@git.wordpress.org> Co-authored-by: Ref34t <mokhaled@git.wordpress.org> Co-authored-by: karmatosed <karmatosed@git.wordpress.org> Co-authored-by: mathetos <webdevmattcrom@git.wordpress.org>
2 parents aeeb829 + 70f9280 commit e1d774b

File tree

11 files changed

+1131
-25
lines changed

11 files changed

+1131
-25
lines changed
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<?php
2+
/**
3+
* Content summarization WordPress Ability implementation.
4+
*
5+
* @package WordPress\AI
6+
*/
7+
8+
declare( strict_types=1 );
9+
10+
namespace WordPress\AI\Abilities\Summarization;
11+
12+
use WP_Error;
13+
use WordPress\AI\Abstracts\Abstract_Ability;
14+
use WordPress\AI_Client\AI_Client;
15+
16+
use function WordPress\AI\get_post_context;
17+
use function WordPress\AI\get_preferred_models;
18+
use function WordPress\AI\normalize_content;
19+
20+
/**
21+
* Content summarization WordPress Ability.
22+
*
23+
* @since x.x.x
24+
*/
25+
class Summarization extends Abstract_Ability {
26+
27+
/**
28+
* The default length of the summary.
29+
*
30+
* @since x.x.x
31+
*
32+
* @var string
33+
*/
34+
protected const LENGTH_DEFAULT = 'medium';
35+
36+
/**
37+
* {@inheritDoc}
38+
*
39+
* @since x.x.x
40+
*/
41+
protected function input_schema(): array {
42+
return array(
43+
'type' => 'object',
44+
'properties' => array(
45+
'content' => array(
46+
'type' => 'string',
47+
'sanitize_callback' => 'sanitize_text_field',
48+
'description' => esc_html__( 'Content to summarize.', 'ai' ),
49+
),
50+
'context' => array(
51+
'type' => 'string',
52+
'sanitize_callback' => 'sanitize_text_field',
53+
'description' => esc_html__( 'Additional context to use when summarizing the content. This can either be a string of additional context or can be a post ID that will then be used to get context from that post (if it exists). If no content is provided but a valid post ID is used here, the content from that post will be used.', 'ai' ),
54+
),
55+
'length' => array(
56+
'type' => 'enum',
57+
'enum' => array( 'short', 'medium', 'long' ),
58+
'default' => self::LENGTH_DEFAULT,
59+
'description' => esc_html__( 'The length of the summary.', 'ai' ),
60+
),
61+
),
62+
);
63+
}
64+
65+
/**
66+
* {@inheritDoc}
67+
*
68+
* @since x.x.x
69+
*/
70+
protected function output_schema(): array {
71+
return array(
72+
'type' => 'string',
73+
'description' => esc_html__( 'The summary of the content.', 'ai' ),
74+
);
75+
}
76+
77+
/**
78+
* {@inheritDoc}
79+
*
80+
* @since x.x.x
81+
*/
82+
protected function execute_callback( $input ) {
83+
// Default arguments.
84+
$args = wp_parse_args(
85+
$input,
86+
array(
87+
'content' => null,
88+
'context' => null,
89+
'length' => self::LENGTH_DEFAULT,
90+
),
91+
);
92+
93+
// If a post ID is provided, ensure the post exists before using its' content.
94+
if ( is_numeric( $args['context'] ) ) {
95+
$post = get_post( (int) $args['context'] );
96+
97+
if ( ! $post ) {
98+
return new WP_Error(
99+
'post_not_found',
100+
/* translators: %d: Post ID. */
101+
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['context'] ) )
102+
);
103+
}
104+
105+
// Get the post context.
106+
$context = get_post_context( $post->ID );
107+
$content = $context['content'] ?? '';
108+
unset( $context['content'] );
109+
110+
// Default to the passed in content if it exists.
111+
if ( $args['content'] ) {
112+
$content = normalize_content( $args['content'] );
113+
}
114+
} else {
115+
$content = normalize_content( $args['content'] ?? '' );
116+
$context = $args['context'] ?? '';
117+
}
118+
119+
// If we have no content, return an error.
120+
if ( empty( $content ) ) {
121+
return new WP_Error(
122+
'content_not_provided',
123+
esc_html__( 'Content is required to generate a summary.', 'ai' )
124+
);
125+
}
126+
127+
// Generate the summary.
128+
$result = $this->generate_summary( $content, $context, $args['length'] );
129+
130+
// If we have an error, return it.
131+
if ( is_wp_error( $result ) ) {
132+
return $result;
133+
}
134+
135+
// If we have no results, return an error.
136+
if ( empty( $result ) ) {
137+
return new WP_Error(
138+
'no_results',
139+
esc_html__( 'No summary was generated.', 'ai' )
140+
);
141+
}
142+
143+
// Return the summary in the format the Ability expects.
144+
return sanitize_text_field( trim( $result ) );
145+
}
146+
147+
/**
148+
* {@inheritDoc}
149+
*
150+
* @since x.x.x
151+
*/
152+
protected function permission_callback( $args ) {
153+
$post_id = isset( $args['context'] ) && is_numeric( $args['context'] ) ? absint( $args['context'] ) : null;
154+
155+
if ( $post_id ) {
156+
$post = get_post( $post_id );
157+
158+
// Ensure the post exists.
159+
if ( ! $post ) {
160+
return new WP_Error(
161+
'post_not_found',
162+
/* translators: %d: Post ID. */
163+
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $post_id ) )
164+
);
165+
}
166+
167+
// Ensure the user has permission to read this particular post.
168+
if ( ! current_user_can( 'read_post', $post_id ) ) {
169+
return new WP_Error(
170+
'insufficient_capabilities',
171+
esc_html__( 'You do not have permission to summarize this post.', 'ai' )
172+
);
173+
}
174+
175+
// Ensure the post type is allowed in REST endpoints.
176+
$post_type = get_post_type( $post_id );
177+
178+
if ( ! $post_type ) {
179+
return false;
180+
}
181+
182+
$post_type_obj = get_post_type_object( $post_type );
183+
184+
if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) {
185+
return false;
186+
}
187+
} elseif ( ! current_user_can( 'edit_posts' ) ) {
188+
// Ensure the user has permission to edit posts in general.
189+
return new WP_Error(
190+
'insufficient_capabilities',
191+
esc_html__( 'You do not have permission to summarize content.', 'ai' )
192+
);
193+
}
194+
195+
return true;
196+
}
197+
198+
/**
199+
* {@inheritDoc}
200+
*
201+
* @since x.x.x
202+
*/
203+
protected function meta(): array {
204+
return array(
205+
'show_in_rest' => true,
206+
);
207+
}
208+
209+
/**
210+
* Generates a summary from the given content.
211+
*
212+
* @since x.x.x
213+
*
214+
* @param string $content The content to summarize.
215+
* @param string|array<string, string> $context Additional context to use.
216+
* @param string $length The desired length of the summary.
217+
* @return string|\WP_Error The generated summary, or a WP_Error if there was an error.
218+
*/
219+
protected function generate_summary( string $content, $context, string $length ) {
220+
// Convert the context to a string if it's an array.
221+
if ( is_array( $context ) ) {
222+
$context = implode(
223+
"\n",
224+
array_map(
225+
static function ( $key, $value ) {
226+
return sprintf(
227+
'%s: %s',
228+
ucwords( str_replace( '_', ' ', $key ) ),
229+
$value
230+
);
231+
},
232+
array_keys( $context ),
233+
$context
234+
)
235+
);
236+
}
237+
238+
$content = '<content>' . $content . '</content>';
239+
240+
// If we have additional context, add it to the content.
241+
if ( $context ) {
242+
$content .= "\n\n<additional-context>" . $context . '</additional-context>';
243+
}
244+
245+
// Generate the summary using the AI client.
246+
return AI_Client::prompt_with_wp_error( $content )
247+
->using_system_instruction( $this->get_system_instruction( 'system-instruction.php', array( 'length' => $length ) ) )
248+
->using_temperature( 0.9 )
249+
->using_model_preference( ...get_preferred_models() )
250+
->generate_text();
251+
}
252+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
/**
3+
* System instruction for the Summarization ability.
4+
*
5+
* @package WordPress\AI\Abilities\Summarization
6+
*/
7+
8+
// Exit if accessed directly.
9+
if ( ! defined( 'ABSPATH' ) ) {
10+
exit;
11+
}
12+
13+
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
14+
15+
// Determine the length from the passed in global.
16+
$length_desc = '2-3 sentences; 25-80 words';
17+
if ( isset( $length ) ) {
18+
if ( 'short' === $length ) {
19+
$length_desc = '1 sentence; <= 25 words';
20+
} elseif ( 'long' === $length ) {
21+
$length_desc = '4-6 sentences; 80-160 words';
22+
}
23+
}
24+
25+
// phpcs:ignore Squiz.PHP.Heredoc.NotAllowed, PluginCheck.CodeAnalysis.Heredoc.NotAllowed
26+
return <<<INSTRUCTION
27+
You are an editorial assistant that generates concise, factual, and neutral summaries of long-form content. Your summaries support both inline readability (e.g., top-of-post overview) and structured metadata use cases (search previews, featured cards, accessibility tools).
28+
29+
Goal: You will be provided with content and optionally some additional context. You will then generate a concise, factual, and neutral summary of that content that also keeps in mind the context. Write in complete sentences, avoid persuasive or stylistic language, do not use humor or exaggeration, and do not introduce information not present in the source.
30+
31+
The summary should follow these requirements:
32+
33+
- Target {$length_desc}
34+
- Should not contain any markdown, bullets, numbering, or formatting - plain text only
35+
- Provide a high-level overview, not a list of details
36+
- Do not start with "This article is about..." or "This post explains..." or "This content describes..." or any other generic introduction
37+
- Must reflect the actual content, not generic filler text
38+
INSTRUCTION;

includes/Abstracts/Abstract_Ability.php

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,14 @@ abstract protected function meta(): array;
118118
*
119119
* @since 0.1.0
120120
*
121-
* @param string|null $filename Optional. Explicit filename to load. If not provided,
122-
* attempts to load `system-instruction.php` or `prompt.php`.
121+
* @param string|null $filename Optional. Explicit filename to load. If not provided,
122+
* attempts to load `system-instruction.php` or `prompt.php`.
123+
* @param array<string, mixed> $data Optional. Data to expose to the system instruction file.
124+
* This data will be extracted as variables available in the file scope.
123125
* @return string The system instruction for the feature.
124126
*/
125-
public function get_system_instruction( ?string $filename = null ): string {
126-
return $this->load_system_instruction_from_file( $filename );
127+
public function get_system_instruction( ?string $filename = null, array $data = array() ): string {
128+
return $this->load_system_instruction_from_file( $filename, $data );
127129
}
128130

129131
/**
@@ -135,13 +137,19 @@ public function get_system_instruction( ?string $filename = null ): string {
135137
* return 'Your system instruction text here...';
136138
* ```
137139
*
140+
* If data is provided, it will be extracted as variables available in the file scope.
141+
* For example, if you pass `array( 'length' => 'short' )`, the variable `$length`
142+
* will be available in the system instruction file.
143+
*
138144
* @since 0.1.0
139145
*
140-
* @param string|null $filename Optional. Explicit filename to load. If not provided,
141-
* attempts to load `system-instruction.php`.
146+
* @param string|null $filename Optional. Explicit filename to load. If not provided,
147+
* attempts to load `system-instruction.php`.
148+
* @param array<string, mixed> $data Optional. Data to expose to the system instruction file.
149+
* This data will be extracted as variables available in the file scope.
142150
* @return string The contents of the file, or empty string if file not found.
143151
*/
144-
protected function load_system_instruction_from_file( ?string $filename = null ): string {
152+
protected function load_system_instruction_from_file( ?string $filename = null, array $data = array() ): string {
145153
// Get the feature's directory using reflection.
146154
$reflection = new ReflectionClass( $this );
147155
$file_name = $reflection->getFileName();
@@ -152,6 +160,11 @@ protected function load_system_instruction_from_file( ?string $filename = null )
152160

153161
$feature_dir = dirname( $file_name );
154162

163+
// Extract data into variables for use in the included file.
164+
if ( ! empty( $data ) ) {
165+
extract( $data, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract
166+
}
167+
155168
// If explicit filename provided, use it.
156169
if ( null !== $filename ) {
157170
$file_path = trailingslashit( $feature_dir ) . $filename;

includes/Experiment_Loader.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,10 @@ public function register_default_experiments(): void {
104104
*/
105105
private function get_default_experiments(): array {
106106
$experiment_classes = array(
107+
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
107108
\WordPress\AI\Experiments\Image_Generation\Image_Generation::class,
109+
\WordPress\AI\Experiments\Summarization\Summarization::class,
108110
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
109-
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
110111
);
111112

112113
/**

0 commit comments

Comments
 (0)