PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / Services / Guidelines.php

Guidelines.php in AI trunk, at includes/Services/Guidelines.php

379 lines 8.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Guidelines service.
4 *
5 * Fetches and caches Guidelines from Gutenberg's wp_guideline CPT.
6 *
7 * @package WordPress\AI\Services
8 */
9
10 declare( strict_types=1 );
11
12 namespace WordPress\AI\Services;
13
14 use WP_Query;
15
16 /**
17 * Guidelines service class.
18 *
19 * Provides a centralized interface for fetching and formatting Guidelines
20 * from the wp_guideline custom post type. Requires Gutenberg 23.0+.
21 *
22 * @since 0.8.0
23 */
24 class Guidelines {
25
26 /**
27 * Post type slug.
28 *
29 * @since 0.8.0
30 *
31 * @var string
32 */
33 public const POST_TYPE = 'wp_guideline';
34
35 /**
36 * Taxonomy slug used by Gutenberg 23.1+ to split guideline posts by purpose.
37 *
38 * @since 1.0.1
39 *
40 * @var string
41 */
42 public const TAXONOMY = 'wp_guideline_type';
43
44 /**
45 * Term slug for the site-wide content guideline singleton.
46 *
47 * @since 1.0.1
48 *
49 * @var string
50 */
51 public const TERM_CONTENT = 'content';
52
53 /**
54 * Singleton instance.
55 *
56 * @since 0.8.0
57 *
58 * @var self|null
59 */
60 private static ?self $instance = null;
61
62 /**
63 * Cached guidelines data.
64 *
65 * @since 0.8.0
66 *
67 * @var array<string, string>|false|null False means not yet fetched.
68 */
69 private static $cached_guidelines = false;
70
71 /**
72 * Cached guidelines post ID.
73 *
74 * @since 0.8.0
75 *
76 * @var int|null
77 */
78 private static ?int $cached_post_id = null;
79
80 /**
81 * Post meta keys for each guideline category.
82 *
83 * @since 0.8.0
84 *
85 * @var array<string, string>
86 */
87 // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition
88 private const CATEGORY_META_KEYS = array(
89 'copy' => '_guideline_copy',
90 'images' => '_guideline_images',
91 'site' => '_guideline_site',
92 'additional' => '_guideline_additional',
93 );
94
95 /**
96 * XML tag names for each guideline category.
97 *
98 * @since 0.8.0
99 *
100 * @var array<string, string>
101 */
102 // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition.DisallowedMultiConstantDefinition
103 private const CATEGORY_TAG_NAMES = array(
104 'site' => 'site-context',
105 'copy' => 'copy-guidelines',
106 'images' => 'image-guidelines',
107 'additional' => 'additional-guidelines',
108 );
109
110 /**
111 * Default maximum character length per guideline category.
112 *
113 * @since 0.8.0
114 *
115 * @var int
116 */
117 private const DEFAULT_MAX_GUIDELINE_LENGTH = 5000;
118
119 /**
120 * Gets the singleton instance.
121 *
122 * @since 0.8.0
123 *
124 * @return self The singleton instance.
125 */
126 public static function get_instance(): self {
127 if ( null === self::$instance ) {
128 self::$instance = new self();
129 }
130 return self::$instance;
131 }
132
133 /**
134 * Private constructor to enforce singleton pattern.
135 *
136 * @since 0.8.0
137 */
138 private function __construct() {}
139
140 /**
141 * Checks if the Guidelines feature is available.
142 *
143 * @since 0.8.0
144 *
145 * @return bool True if the guidelines CPT is registered.
146 */
147 public function is_available(): bool {
148 return post_type_exists( self::POST_TYPE );
149 }
150
151 /**
152 * Retrieves guidelines, optionally filtered by category.
153 *
154 * @since 0.8.0
155 *
156 * @param string|null $category Optional. Guideline category to retrieve ('site', 'copy', 'images', 'additional').
157 * @return array<string, string>|null Keyed array of guidelines, or null when unavailable.
158 */
159 public function get_guidelines( ?string $category = null ): ?array {
160 if ( ! $this->should_use_guidelines() ) {
161 return null;
162 }
163
164 $guidelines = $this->fetch_guidelines();
165
166 if ( null === $guidelines ) {
167 return null;
168 }
169
170 if ( null !== $category ) {
171 if ( ! isset( $guidelines[ $category ] ) ) {
172 return null;
173 }
174 return array( $category => $guidelines[ $category ] );
175 }
176
177 return $guidelines;
178 }
179
180 /**
181 * Retrieves guidelines for a specific block type.
182 *
183 * @since 0.8.0
184 *
185 * @param string $block_name The block name (e.g., 'core/paragraph').
186 * @return string|null The block-specific guidelines, or null if unavailable.
187 */
188 public function get_block_guidelines( string $block_name ): ?string {
189 if ( ! $this->should_use_guidelines() ) {
190 return null;
191 }
192
193 $this->fetch_guidelines();
194
195 if ( null === self::$cached_post_id ) {
196 return null;
197 }
198
199 $sanitized_name = str_replace( '/', '_', $block_name );
200 $meta_key = '_guideline_block_' . $sanitized_name;
201 $value = get_post_meta( self::$cached_post_id, $meta_key, true );
202
203 if ( ! is_string( $value ) || '' === $value ) {
204 return null;
205 }
206
207 return $value;
208 }
209
210 /**
211 * Formats guidelines as an XML-tagged string suitable for prompt injection.
212 *
213 * @since 0.8.0
214 *
215 * @param list<string> $categories Guideline category slugs to include.
216 * @param string|null $block_name Optional block name for block-specific guidelines.
217 * @return string Formatted guidelines XML string, or empty string if nothing to include.
218 */
219 public function format_for_prompt( array $categories, ?string $block_name = null ): string {
220 if ( ! $this->should_use_guidelines() ) {
221 return '';
222 }
223
224 $guidelines = $this->fetch_guidelines();
225
226 if ( null === $guidelines ) {
227 return '';
228 }
229
230 /**
231 * Filters the maximum character length per guideline category.
232 *
233 * @since 0.8.0
234 *
235 * @param int $max_length The maximum character length per category. Default 2000.
236 * @return int The filtered maximum length.
237 */
238 $max_length = (int) apply_filters( 'wpai_max_guideline_length', self::DEFAULT_MAX_GUIDELINE_LENGTH );
239
240 $parts = array();
241
242 foreach ( $categories as $category ) {
243 if ( ! isset( $guidelines[ $category ] ) || '' === $guidelines[ $category ] ) {
244 continue;
245 }
246
247 $tag_name = self::CATEGORY_TAG_NAMES[ $category ] ?? $category;
248 $content = wp_strip_all_tags( $guidelines[ $category ] );
249
250 if ( mb_strlen( $content, 'UTF-8' ) > $max_length ) {
251 $content = mb_substr( $content, 0, $max_length, 'UTF-8' );
252 }
253
254 $parts[] = '<' . $tag_name . '>' . $content . '</' . $tag_name . '>';
255 }
256
257 // Add block-specific guidelines if requested.
258 if ( null !== $block_name ) {
259 $block_guidelines = $this->get_block_guidelines( $block_name );
260 if ( null !== $block_guidelines ) {
261 $block_content = wp_strip_all_tags( $block_guidelines );
262 if ( mb_strlen( $block_content, 'UTF-8' ) > $max_length ) {
263 $block_content = mb_substr( $block_content, 0, $max_length, 'UTF-8' );
264 }
265 $parts[] = '<block-guidelines>' . $block_content . '</block-guidelines>';
266 }
267 }
268
269 if ( empty( $parts ) ) {
270 return '';
271 }
272
273 return '<guidelines>' . "\n" . implode( "\n", $parts ) . "\n" . '</guidelines>';
274 }
275
276 /**
277 * Resets the internal cache. Intended for use in tests.
278 *
279 * @since 0.8.0
280 *
281 * @return void
282 */
283 public static function reset_cache(): void {
284 self::$cached_guidelines = false;
285 self::$cached_post_id = null;
286 }
287
288 /**
289 * Checks whether guidelines should be used.
290 *
291 * @since 0.8.0
292 *
293 * @return bool True if guidelines should be used.
294 */
295 private function should_use_guidelines(): bool {
296 if ( ! $this->is_available() ) {
297 return false;
298 }
299
300 /**
301 * Filters whether guidelines integration is enabled.
302 *
303 * @since 0.8.0
304 *
305 * @param bool $use_guidelines Whether to use guidelines. Default true.
306 * @return bool Whether to use guidelines.
307 */
308 return (bool) apply_filters( 'wpai_use_guidelines', true );
309 }
310
311 /**
312 * Fetches guidelines from the database, using cache when available.
313 *
314 * @since 0.8.0
315 *
316 * @return array<string, string>|null Keyed array of guidelines, or null when unavailable.
317 */
318 private function fetch_guidelines(): ?array {
319 // Return cached result if available.
320 if ( false !== self::$cached_guidelines ) {
321 return self::$cached_guidelines;
322 }
323
324 // Gutenberg saves guidelines as 'draft' by default; both statuses are valid.
325 $query_args = array(
326 'post_type' => self::POST_TYPE,
327 'posts_per_page' => 1,
328 'post_status' => array( 'publish', 'draft' ),
329 'orderby' => 'date',
330 'order' => 'DESC',
331 'no_found_rows' => true,
332 );
333
334 // Gutenberg 23.1+ splits guidelines into types via the wp_guideline_type
335 // taxonomy. Without this filter the newest artifact guideline would shadow
336 // the content singleton in prompt assembly. On older Gutenberg the
337 // taxonomy isn't registered and we fall back to the legacy "newest wins".
338 if ( taxonomy_exists( self::TAXONOMY ) ) {
339 $query_args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
340 array(
341 'taxonomy' => self::TAXONOMY,
342 'field' => 'slug',
343 'terms' => self::TERM_CONTENT,
344 ),
345 );
346 }
347
348 $query = new WP_Query( $query_args );
349
350 $post = $query->posts[0] ?? null;
351
352 if ( ! $post instanceof \WP_Post ) {
353 self::$cached_guidelines = null;
354 return null;
355 }
356
357 self::$cached_post_id = $post->ID;
358
359 $guidelines = array();
360
361 foreach ( self::CATEGORY_META_KEYS as $category => $meta_key ) {
362 $value = get_post_meta( $post->ID, $meta_key, true );
363 if ( ! is_string( $value ) || '' === $value ) {
364 continue;
365 }
366
367 $guidelines[ $category ] = $value;
368 }
369
370 if ( empty( $guidelines ) ) {
371 self::$cached_guidelines = null;
372 return null;
373 }
374
375 self::$cached_guidelines = $guidelines;
376 return $guidelines;
377 }
378 }
379